Add validation pipeline, sandbox, and evolution DB layer (Phase 7)

Three-stage fail-fast validator for LLM-generated bot candidates:
- syntax.go: language-aware parse (go/parser for Go; py_compile, rustfmt,
  tsc, javac, php -l for others; brace-balance fallback)
- schema.go: regex detection of /health + /turn endpoints and "moves" field
- sandbox.go: nsjail-isolated smoke test — builds bot, polls /health, sends
  5 signed /turn requests, verifies JSON moves responses
- validator.go: orchestrates stages with fail-fast short-circuit

DB layer:
- programs table + CRUD (create, get, list, updateFitness, setPromoted)
- validation_log table with RecordValidation, IslandPassRates,
  IslandValidationStats for per-island pass-rate tracking
- seed.go: 6 generation-0 bots across alpha/beta/gamma/delta islands

MAP-Elites grid (mapelites/grid.go): 2-D behavior grid on aggression×economy
axes; TryPlace keeps the fittest occupant per niche.

acb-evolver CLI gains two new subcommands:
  validate <file> -lang <lang> [-island <island>] [-nsjail] [-nolog]
  validation-stats (tabular per-island pass-rate breakdown)

cmd/acb-api/db.go: add programs table to API schema so the API can query
promoted evolved bots.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-03-26 22:45:13 -04:00
parent bd4b0d3244
commit 5669688984
20 changed files with 4047 additions and 0 deletions

View file

@ -66,6 +66,21 @@ CREATE TABLE IF NOT EXISTS rating_history (
PRIMARY KEY (bot_id, match_id)
);
CREATE INDEX IF NOT EXISTS idx_rating_history_bot ON rating_history(bot_id, recorded_at);
CREATE TABLE IF NOT EXISTS programs (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL,
language VARCHAR(32) NOT NULL,
island VARCHAR(16) NOT NULL,
generation INTEGER NOT NULL DEFAULT 0,
parent_ids JSONB NOT NULL DEFAULT '[]',
behavior_vector DOUBLE PRECISION[] NOT NULL DEFAULT '{}',
fitness DOUBLE PRECISION NOT NULL DEFAULT 0.0,
promoted BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_programs_island ON programs(island);
CREATE INDEX IF NOT EXISTS idx_programs_island_fitness ON programs(island, fitness DESC);
`
func ensureSchema(ctx context.Context, db *sql.DB) error {

View file

@ -0,0 +1,44 @@
// Package db provides database access for the evolution pipeline.
package db
import (
"context"
"database/sql"
)
// schemaSQL creates the programs and validation_log tables with their indexes.
const schemaSQL = `
CREATE TABLE IF NOT EXISTS programs (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL,
language VARCHAR(32) NOT NULL,
island VARCHAR(16) NOT NULL,
generation INTEGER NOT NULL DEFAULT 0,
parent_ids JSONB NOT NULL DEFAULT '[]',
behavior_vector DOUBLE PRECISION[] NOT NULL DEFAULT '{}',
fitness DOUBLE PRECISION NOT NULL DEFAULT 0.0,
promoted BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_programs_island ON programs(island);
CREATE INDEX IF NOT EXISTS idx_programs_island_fitness ON programs(island, fitness DESC);
CREATE TABLE IF NOT EXISTS validation_log (
id BIGSERIAL PRIMARY KEY,
island VARCHAR(16) NOT NULL,
language VARCHAR(32) NOT NULL,
stage VARCHAR(16) NOT NULL, -- last stage attempted: syntax / schema / sandbox
passed BOOLEAN NOT NULL,
error_text TEXT NOT NULL DEFAULT '',
llm_output TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_validation_log_island ON validation_log(island);
CREATE INDEX IF NOT EXISTS idx_validation_log_island_passed ON validation_log(island, passed);
`
// EnsureSchema creates the programs table if it does not already exist.
func EnsureSchema(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, schemaSQL)
return err
}

View file

@ -0,0 +1,179 @@
package db
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/lib/pq"
)
// Island names for the 4 independent populations.
const (
IslandAlpha = "alpha" // core-rushing strategies
IslandBeta = "beta" // energy-focused strategies
IslandGamma = "gamma" // defensive strategies
IslandDelta = "delta" // mixed / experimental
)
// AllIslands is the ordered list of the 4 island names.
var AllIslands = []string{IslandAlpha, IslandBeta, IslandGamma, IslandDelta}
// Program represents an evolved strategy program stored in the database.
// BehaviorVector is a 2-element slice: [aggression, economy], each in [0, 1].
type Program struct {
ID int64
Code string
Language string
Island string
Generation int
ParentIDs []int64
BehaviorVector []float64
Fitness float64
Promoted bool
CreatedAt time.Time
}
// Store provides CRUD operations for programs.
type Store struct {
db *sql.DB
}
// NewStore creates a Store backed by the given database connection.
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
// Create inserts a new program and returns its assigned ID.
func (s *Store) Create(ctx context.Context, p *Program) (int64, error) {
parentJSON, err := json.Marshal(p.ParentIDs)
if err != nil {
return 0, fmt.Errorf("marshal parent_ids: %w", err)
}
var id int64
err = s.db.QueryRowContext(ctx, `
INSERT INTO programs (code, language, island, generation, parent_ids, behavior_vector, fitness, promoted)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id`,
p.Code,
p.Language,
p.Island,
p.Generation,
string(parentJSON),
pq.Array(p.BehaviorVector),
p.Fitness,
p.Promoted,
).Scan(&id)
if err != nil {
return 0, fmt.Errorf("insert program: %w", err)
}
return id, nil
}
// Get retrieves a program by ID. Returns (nil, nil) if not found.
func (s *Store) Get(ctx context.Context, id int64) (*Program, error) {
p := &Program{}
var parentJSON string
err := s.db.QueryRowContext(ctx, `
SELECT id, code, language, island, generation, parent_ids,
behavior_vector, fitness, promoted, created_at
FROM programs WHERE id = $1`, id).Scan(
&p.ID, &p.Code, &p.Language, &p.Island, &p.Generation,
&parentJSON, pq.Array(&p.BehaviorVector), &p.Fitness, &p.Promoted, &p.CreatedAt,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get program %d: %w", id, err)
}
if err := json.Unmarshal([]byte(parentJSON), &p.ParentIDs); err != nil {
return nil, fmt.Errorf("unmarshal parent_ids: %w", err)
}
return p, nil
}
// ListByIsland returns all programs on the given island ordered by fitness desc.
func (s *Store) ListByIsland(ctx context.Context, island string) ([]*Program, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT id, code, language, island, generation, parent_ids,
behavior_vector, fitness, promoted, created_at
FROM programs WHERE island = $1
ORDER BY fitness DESC`, island)
if err != nil {
return nil, fmt.Errorf("list programs on %s: %w", island, err)
}
defer rows.Close()
var programs []*Program
for rows.Next() {
p := &Program{}
var parentJSON string
if err := rows.Scan(
&p.ID, &p.Code, &p.Language, &p.Island, &p.Generation,
&parentJSON, pq.Array(&p.BehaviorVector), &p.Fitness, &p.Promoted, &p.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan program: %w", err)
}
if err := json.Unmarshal([]byte(parentJSON), &p.ParentIDs); err != nil {
return nil, fmt.Errorf("unmarshal parent_ids: %w", err)
}
programs = append(programs, p)
}
return programs, rows.Err()
}
// UpdateFitness updates the fitness score and behavior vector of a program.
func (s *Store) UpdateFitness(ctx context.Context, id int64, fitness float64, behaviorVec []float64) error {
_, err := s.db.ExecContext(ctx, `
UPDATE programs SET fitness = $1, behavior_vector = $2 WHERE id = $3`,
fitness, pq.Array(behaviorVec), id,
)
if err != nil {
return fmt.Errorf("update fitness for program %d: %w", id, err)
}
return nil
}
// SetPromoted marks a program as promoted to the live bot fleet.
func (s *Store) SetPromoted(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `UPDATE programs SET promoted = TRUE WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("set promoted for program %d: %w", id, err)
}
return nil
}
// CountByIsland returns the number of programs on each island.
func (s *Store) CountByIsland(ctx context.Context) (map[string]int, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT island, COUNT(*) FROM programs GROUP BY island`)
if err != nil {
return nil, fmt.Errorf("count by island: %w", err)
}
defer rows.Close()
counts := make(map[string]int)
for rows.Next() {
var island string
var count int
if err := rows.Scan(&island, &count); err != nil {
return nil, fmt.Errorf("scan island count: %w", err)
}
counts[island] = count
}
return counts, rows.Err()
}
// TotalCount returns the total number of programs across all islands.
func (s *Store) TotalCount(ctx context.Context) (int, error) {
var n int
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM programs`).Scan(&n)
if err != nil {
return 0, fmt.Errorf("total count: %w", err)
}
return n, nil
}

View file

@ -0,0 +1,266 @@
package db
import (
"context"
"database/sql"
"os"
"testing"
_ "github.com/lib/pq"
)
// openTestDB opens a PostgreSQL connection using ACB_TEST_DATABASE_URL.
// Tests that call this function are skipped when the env var is absent.
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
dsn := os.Getenv("ACB_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("ACB_TEST_DATABASE_URL not set; skipping DB integration test")
}
db, err := sql.Open("postgres", dsn)
if err != nil {
t.Fatalf("open test DB: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
// setupTestSchema creates the programs table and registers cleanup to drop it.
func setupTestSchema(t *testing.T, db *sql.DB) {
t.Helper()
ctx := context.Background()
if err := EnsureSchema(ctx, db); err != nil {
t.Fatalf("ensure schema: %v", err)
}
t.Cleanup(func() {
db.ExecContext(ctx, `DROP TABLE IF EXISTS programs`)
})
}
func TestCreate_Get(t *testing.T) {
db := openTestDB(t)
setupTestSchema(t, db)
s := NewStore(db)
ctx := context.Background()
p := &Program{
Code: "func strategy() {}",
Language: "go",
Island: IslandAlpha,
Generation: 0,
ParentIDs: []int64{},
BehaviorVector: []float64{0.9, 0.2},
Fitness: 0.0,
Promoted: false,
}
id, err := s.Create(ctx, p)
if err != nil {
t.Fatalf("Create: %v", err)
}
if id <= 0 {
t.Fatalf("expected positive ID, got %d", id)
}
got, err := s.Get(ctx, id)
if err != nil {
t.Fatalf("Get: %v", err)
}
if got == nil {
t.Fatal("Get returned nil for existing program")
}
if got.Code != p.Code {
t.Errorf("Code: got %q, want %q", got.Code, p.Code)
}
if got.Language != p.Language {
t.Errorf("Language: got %q, want %q", got.Language, p.Language)
}
if got.Island != p.Island {
t.Errorf("Island: got %q, want %q", got.Island, p.Island)
}
if len(got.BehaviorVector) != 2 || got.BehaviorVector[0] != 0.9 || got.BehaviorVector[1] != 0.2 {
t.Errorf("BehaviorVector: got %v, want [0.9 0.2]", got.BehaviorVector)
}
}
func TestGet_NotFound(t *testing.T) {
db := openTestDB(t)
setupTestSchema(t, db)
s := NewStore(db)
ctx := context.Background()
got, err := s.Get(ctx, 999999)
if err != nil {
t.Fatalf("Get non-existent: %v", err)
}
if got != nil {
t.Error("expected nil for non-existent program")
}
}
func TestListByIsland(t *testing.T) {
db := openTestDB(t)
setupTestSchema(t, db)
s := NewStore(db)
ctx := context.Background()
programs := []*Program{
{Code: "a", Language: "go", Island: IslandAlpha, BehaviorVector: []float64{0.9, 0.2}, Fitness: 10.0, ParentIDs: []int64{}},
{Code: "b", Language: "go", Island: IslandAlpha, BehaviorVector: []float64{0.8, 0.3}, Fitness: 5.0, ParentIDs: []int64{}},
{Code: "c", Language: "go", Island: IslandBeta, BehaviorVector: []float64{0.1, 0.9}, Fitness: 8.0, ParentIDs: []int64{}},
}
for _, p := range programs {
if _, err := s.Create(ctx, p); err != nil {
t.Fatalf("Create: %v", err)
}
}
alphaList, err := s.ListByIsland(ctx, IslandAlpha)
if err != nil {
t.Fatalf("ListByIsland: %v", err)
}
if len(alphaList) != 2 {
t.Fatalf("expected 2 programs on alpha, got %d", len(alphaList))
}
// Verify descending fitness order
if alphaList[0].Fitness < alphaList[1].Fitness {
t.Error("expected programs ordered by fitness DESC")
}
betaList, err := s.ListByIsland(ctx, IslandBeta)
if err != nil {
t.Fatalf("ListByIsland beta: %v", err)
}
if len(betaList) != 1 {
t.Fatalf("expected 1 program on beta, got %d", len(betaList))
}
// Empty island returns empty slice (not an error)
gammaList, err := s.ListByIsland(ctx, IslandGamma)
if err != nil {
t.Fatalf("ListByIsland gamma: %v", err)
}
if len(gammaList) != 0 {
t.Errorf("expected empty gamma island, got %d programs", len(gammaList))
}
}
func TestUpdateFitness(t *testing.T) {
db := openTestDB(t)
setupTestSchema(t, db)
s := NewStore(db)
ctx := context.Background()
id, err := s.Create(ctx, &Program{
Code: "x", Language: "go", Island: IslandDelta,
BehaviorVector: []float64{0.3, 0.4}, ParentIDs: []int64{},
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if err := s.UpdateFitness(ctx, id, 42.5, []float64{0.35, 0.45}); err != nil {
t.Fatalf("UpdateFitness: %v", err)
}
got, _ := s.Get(ctx, id)
if got.Fitness != 42.5 {
t.Errorf("Fitness: got %f, want 42.5", got.Fitness)
}
if len(got.BehaviorVector) != 2 || got.BehaviorVector[0] != 0.35 {
t.Errorf("BehaviorVector after update: got %v", got.BehaviorVector)
}
}
func TestSetPromoted(t *testing.T) {
db := openTestDB(t)
setupTestSchema(t, db)
s := NewStore(db)
ctx := context.Background()
id, err := s.Create(ctx, &Program{
Code: "y", Language: "rust", Island: IslandGamma,
BehaviorVector: []float64{0.7, 0.3}, ParentIDs: []int64{},
})
if err != nil {
t.Fatalf("Create: %v", err)
}
got, _ := s.Get(ctx, id)
if got.Promoted {
t.Fatal("new program should not be promoted")
}
if err := s.SetPromoted(ctx, id); err != nil {
t.Fatalf("SetPromoted: %v", err)
}
got, _ = s.Get(ctx, id)
if !got.Promoted {
t.Error("program should be promoted after SetPromoted")
}
}
func TestCountByIsland(t *testing.T) {
db := openTestDB(t)
setupTestSchema(t, db)
s := NewStore(db)
ctx := context.Background()
for _, prog := range []*Program{
{Code: "1", Language: "go", Island: IslandAlpha, BehaviorVector: []float64{0.9, 0.1}, ParentIDs: []int64{}},
{Code: "2", Language: "go", Island: IslandAlpha, BehaviorVector: []float64{0.8, 0.2}, ParentIDs: []int64{}},
{Code: "3", Language: "go", Island: IslandBeta, BehaviorVector: []float64{0.1, 0.9}, ParentIDs: []int64{}},
} {
if _, err := s.Create(ctx, prog); err != nil {
t.Fatalf("Create: %v", err)
}
}
counts, err := s.CountByIsland(ctx)
if err != nil {
t.Fatalf("CountByIsland: %v", err)
}
if counts[IslandAlpha] != 2 {
t.Errorf("alpha count: got %d, want 2", counts[IslandAlpha])
}
if counts[IslandBeta] != 1 {
t.Errorf("beta count: got %d, want 1", counts[IslandBeta])
}
}
func TestParentIDs_Roundtrip(t *testing.T) {
db := openTestDB(t)
setupTestSchema(t, db)
s := NewStore(db)
ctx := context.Background()
// First create two parent programs
p1, _ := s.Create(ctx, &Program{Code: "parent1", Language: "go", Island: IslandAlpha, BehaviorVector: []float64{0.9, 0.2}, ParentIDs: []int64{}})
p2, _ := s.Create(ctx, &Program{Code: "parent2", Language: "go", Island: IslandAlpha, BehaviorVector: []float64{0.8, 0.3}, ParentIDs: []int64{}})
// Create child with both parents
childID, err := s.Create(ctx, &Program{
Code: "child", Language: "go", Island: IslandAlpha,
Generation: 1,
ParentIDs: []int64{p1, p2},
BehaviorVector: []float64{0.85, 0.25},
})
if err != nil {
t.Fatalf("Create child: %v", err)
}
child, err := s.Get(ctx, childID)
if err != nil {
t.Fatalf("Get child: %v", err)
}
if len(child.ParentIDs) != 2 {
t.Fatalf("expected 2 parent IDs, got %d", len(child.ParentIDs))
}
if child.ParentIDs[0] != p1 || child.ParentIDs[1] != p2 {
t.Errorf("ParentIDs: got %v, want [%d %d]", child.ParentIDs, p1, p2)
}
if child.Generation != 1 {
t.Errorf("Generation: got %d, want 1", child.Generation)
}
}

View file

@ -0,0 +1,126 @@
package db
import (
"context"
_ "embed"
"fmt"
)
//go:embed seeds/gatherer_strategy.go.txt
var gathererCode string
//go:embed seeds/rusher_strategy.rs.txt
var rusherCode string
//go:embed seeds/swarm_strategy.ts.txt
var swarmCode string
//go:embed seeds/guardian_strategy.php.txt
var guardianCode string
//go:embed seeds/hunter_strategy.java.txt
var hunterCode string
//go:embed seeds/random_main.py.txt
var randomCode string
// seedProgram describes a built-in strategy bot used to bootstrap the
// programs database.
type seedProgram struct {
name string
language string
island string
aggression float64 // behavior_vector[0]
economy float64 // behavior_vector[1]
code string
}
// seeds is the initial population of 6 built-in strategy bots distributed
// across all 4 islands. Each bot is assigned a behavior vector that captures
// its play-style on the aggression × economy axes.
var seeds = []seedProgram{
// beta island economic strategies
{
name: "gatherer",
language: "go",
island: IslandBeta,
aggression: 0.1,
economy: 0.9,
code: gathererCode,
},
{
name: "guardian",
language: "php",
island: IslandBeta,
aggression: 0.2,
economy: 0.6,
code: guardianCode,
},
// alpha island aggressive strategies
{
name: "rusher",
language: "rust",
island: IslandAlpha,
aggression: 0.9,
economy: 0.2,
code: rusherCode,
},
{
name: "swarm",
language: "typescript",
island: IslandAlpha,
aggression: 0.6,
economy: 0.5,
code: swarmCode,
},
// gamma island adaptive / hunting strategies
{
name: "hunter",
language: "java",
island: IslandGamma,
aggression: 0.7,
economy: 0.3,
code: hunterCode,
},
// delta island baseline / experimental
{
name: "random",
language: "python",
island: IslandDelta,
aggression: 0.3,
economy: 0.4,
code: randomCode,
},
}
// SeedPopulation inserts the 6 built-in strategy bots as generation-0
// programs if the programs table is empty. It is idempotent: a second call
// is a no-op.
func SeedPopulation(ctx context.Context, s *Store) (int, error) {
total, err := s.TotalCount(ctx)
if err != nil {
return 0, fmt.Errorf("check existing programs: %w", err)
}
if total > 0 {
return 0, nil
}
inserted := 0
for _, seed := range seeds {
p := &Program{
Code: seed.code,
Language: seed.language,
Island: seed.island,
Generation: 0,
ParentIDs: []int64{},
BehaviorVector: []float64{seed.aggression, seed.economy},
Fitness: 0.0,
Promoted: false,
}
if _, err := s.Create(ctx, p); err != nil {
return inserted, fmt.Errorf("seed %s: %w", seed.name, err)
}
inserted++
}
return inserted, nil
}

View file

@ -0,0 +1,298 @@
package main
import (
"container/list"
)
// GathererStrategy implements energy-focused gameplay with combat avoidance.
type GathererStrategy struct {
// No persistent state needed - strategy is stateless per turn
}
// NewGathererStrategy creates a new gatherer strategy.
func NewGathererStrategy() *GathererStrategy {
return &GathererStrategy{}
}
// ComputeMoves calculates the best moves for the current turn.
func (s *GathererStrategy) ComputeMoves(state *GameState) []Move {
if len(state.Bots) == 0 {
return nil
}
myID := state.You.ID
config := state.Config
// Separate my bots from enemy bots
myBots := make([]VisibleBot, 0)
enemyBots := make([]VisibleBot, 0)
for _, bot := range state.Bots {
if bot.Owner == myID {
myBots = append(myBots, bot)
} else {
enemyBots = append(enemyBots, bot)
}
}
// Build enemy positions map for quick lookup
enemyPositions := make(map[Position]bool)
for _, enemy := range enemyBots {
enemyPositions[enemy.Position] = true
}
// Build energy positions map
energyPositions := make(map[Position]bool)
for _, e := range state.Energy {
energyPositions[e] = true
}
// For each of my bots, find the best move
moves := make([]Move, 0, len(myBots))
usedEnergy := make(map[Position]bool) // Track energy already targeted
for _, bot := range myBots {
move := s.computeBotMove(bot, myBots, enemyBots, enemyPositions,
energyPositions, usedEnergy, config)
if move != nil {
moves = append(moves, *move)
// Mark energy as targeted if bot will collect it
if energyPositions[move.Position] || energyPositions[simulateMove(bot.Position, move.Direction, config)] {
usedEnergy[simulateMove(bot.Position, move.Direction, config)] = true
}
}
}
return moves
}
// computeBotMove calculates the best move for a single bot.
func (s *GathererStrategy) computeBotMove(
bot VisibleBot,
myBots, enemyBots []VisibleBot,
enemyPositions, energyPositions, usedEnergy map[Position]bool,
config GameConfig,
) *Move {
// First check if we should flee from enemies
if s.shouldFlee(bot.Position, enemyBots, config) {
fleeDir := s.getFleeDirection(bot.Position, enemyBots, config)
if fleeDir != "" {
return &Move{
Position: bot.Position,
Direction: fleeDir,
}
}
}
// Try to find nearest untargeted energy
nearestEnergy, path := s.findNearestEnergy(bot.Position, energyPositions, usedEnergy, enemyPositions, config)
if path != nil && len(path) > 0 {
// Move towards the energy
return &Move{
Position: bot.Position,
Direction: path[0],
}
}
// No energy visible or reachable - spread out to explore
return s.getExploreMove(bot.Position, myBots, enemyPositions, config)
}
// shouldFlee returns true if the bot should flee from nearby enemies.
func (s *GathererStrategy) shouldFlee(pos Position, enemies []VisibleBot, config GameConfig) bool {
for _, enemy := range enemies {
dist2 := distance2(pos, enemy.Position, config)
// Flee if enemy is within attack range + 2 tiles buffer
if dist2 <= config.AttackRadius2+4 {
return true
}
}
return false
}
// getFleeDirection returns the best direction to flee from enemies.
func (s *GathererStrategy) getFleeDirection(pos Position, enemies []VisibleBot, config GameConfig) Direction {
// Calculate the center of mass of enemies
enemyCenter := Position{Row: 0, Col: 0}
for _, enemy := range enemies {
enemyCenter.Row += enemy.Position.Row
enemyCenter.Col += enemy.Position.Col
}
if len(enemies) > 0 {
enemyCenter.Row /= len(enemies)
enemyCenter.Col /= len(enemies)
}
// Move away from enemy center
dr := pos.Row - enemyCenter.Row
dc := pos.Col - enemyCenter.Col
// Normalize direction
if dr > 0 {
return DirS
} else if dr < 0 {
return DirN
} else if dc > 0 {
return DirE
} else if dc < 0 {
return DirW
}
// Default: move North
return DirN
}
// findNearestEnergy finds the nearest untargeted energy using BFS.
func (s *GathererStrategy) findNearestEnergy(
start Position,
energyPositions, usedEnergy, enemyPositions map[Position]bool,
config GameConfig,
) (Position, []Direction) {
type queueItem struct {
pos Position
path []Direction
}
visited := make(map[Position]bool)
queue := list.New()
queue.PushBack(queueItem{pos: start, path: []Direction{}})
_ = nearestEnergy // Track found position (unused but semantically meaningful)
var bestPath []Direction
for queue.Len() > 0 {
item := queue.Remove(queue.Front()).(queueItem)
pos := item.pos
path := item.path
if visited[pos] {
continue
}
visited[pos] = true
// Check if this position has untargeted energy
if energyPositions[pos] && !usedEnergy[pos] {
nearestEnergy = pos
bestPath = path
break
}
// Don't path through enemy-adjacent tiles
if len(path) > 0 && s.isNearEnemy(pos, enemyPositions, config) {
continue
}
// Explore neighbors
directions := []Direction{DirN, DirE, DirS, DirW}
for _, dir := range directions {
nextPos := simulateMove(pos, dir, config)
if !visited[nextPos] {
newPath := make([]Direction, len(path)+1)
copy(newPath, path)
newPath[len(path)] = dir
queue.PushBack(queueItem{pos: nextPos, path: newPath})
}
}
}
return nearestEnergy, bestPath
}
// isNearEnemy checks if a position is adjacent to any enemy.
func (s *GathererStrategy) isNearEnemy(pos Position, enemyPositions map[Position]bool, config GameConfig) bool {
directions := []Direction{DirN, DirE, DirS, DirW}
for _, dir := range directions {
adj := simulateMove(pos, dir, config)
if enemyPositions[adj] {
return true
}
}
return false
}
// getExploreMove returns a move for exploring when no energy is visible.
func (s *GathererStrategy) getExploreMove(
pos Position,
myBots []VisibleBot,
enemyPositions map[Position]bool,
config GameConfig,
) *Move {
// Calculate direction away from other friendly bots (spread out)
directions := []Direction{DirN, DirE, DirS, DirW}
bestDir := DirN
bestScore := -999999
for _, dir := range directions {
newPos := simulateMove(pos, dir, config)
// Skip if moving towards enemy
if s.isNearEnemy(newPos, enemyPositions, config) {
continue
}
// Score based on distance from other bots (prefer spreading out)
score := 0
for _, other := range myBots {
if other.Position != pos {
dist := distance2(newPos, other.Position, config)
score += int(dist) // Higher is better (further from others)
}
}
if score > bestScore {
bestScore = score
bestDir = dir
}
}
return &Move{
Position: pos,
Direction: bestDir,
}
}
// distance2 calculates squared Euclidean distance with toroidal wrapping.
func distance2(a, b Position, config GameConfig) int {
dr := abs(a.Row - b.Row)
dc := abs(a.Col - b.Col)
// Apply toroidal wrapping
if dr > config.Rows/2 {
dr = config.Rows - dr
}
if dc > config.Cols/2 {
dc = config.Cols - dc
}
return dr*dr + dc*dc
}
// simulateMove returns the new position after moving in a direction.
func simulateMove(pos Position, dir Direction, config GameConfig) Position {
var newRow, newCol int
switch dir {
case DirN:
newRow = (pos.Row - 1 + config.Rows) % config.Rows
newCol = pos.Col
case DirE:
newRow = pos.Row
newCol = (pos.Col + 1) % config.Cols
case DirS:
newRow = (pos.Row + 1) % config.Rows
newCol = pos.Col
case DirW:
newRow = pos.Row
newCol = (pos.Col - 1 + config.Cols) % config.Cols
default:
return pos
}
return Position{Row: newRow, Col: newCol}
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}

View file

@ -0,0 +1,364 @@
<?php
/**
* GuardianBot strategy: defensive core protection with cautious expansion.
*
* Strategy: Defend own core, gather nearby energy, cautious expansion.
* - Maintain a perimeter of bots within 5 tiles of each owned core
* - Assign excess bots to gather energy within 10 tiles of a core
* - Consolidate defenders when enemies approach
* - Only send scouts to explore beyond the safe zone
* - Conservative spawning - maintains energy reserve of 6
*/
require_once __DIR__ . '/game.php';
class GuardianStrategy {
private const PERIMETER_RADIUS = 5;
private const SAFE_ZONE_RADIUS = 10;
private const ENERGY_RESERVE = 6;
private const DIRECTIONS = ['N', 'E', 'S', 'W'];
/**
* Compute moves for all owned bots
*/
public function computeMoves(GameState $state): array {
$myId = $state->you->id;
$config = $state->config;
// Separate my bots from enemies
$myBots = [];
$enemyBots = [];
foreach ($state->bots as $bot) {
if ($bot->owner === $myId) {
$myBots[] = $bot;
} else {
$enemyBots[] = $bot;
}
}
if (empty($myBots)) {
return [];
}
// Find my cores and enemy cores
$myCores = [];
$enemyCores = [];
foreach ($state->cores as $core) {
if ($core->owner === $myId && $core->active) {
$myCores[] = $core;
} elseif ($core->active) {
$enemyCores[] = $core;
}
}
// Build wall lookup
$walls = $this->buildPositionSet($state->walls);
// Build enemy position lookup
$enemyPositions = $this->buildPositionSet(array_map(fn($b) => $b->position, $enemyBots));
// Build energy position set
$energyPositions = $this->buildPositionSet($state->energy);
// Assign roles to bots
$moves = [];
$usedEnergy = [];
$assignedPositions = [];
// First pass: assign defenders to cores
$defenders = $this->assignDefenders($myBots, $myCores, $enemyBots, $config);
// Second pass: assign gatherers to nearby energy
foreach ($myBots as $bot) {
if (isset($assignedPositions[$this->posKey($bot->position)])) {
continue;
}
// Check if this bot should be a defender
if (isset($defenders[$this->posKey($bot->position)])) {
$move = $this->computeDefenderMove($bot, $defenders[$this->posKey($bot->position)], $enemyBots, $walls, $config);
} elseif ($this->shouldGather($bot, $myCores, $config)) {
$move = $this->computeGatherMove($bot, $energyPositions, $usedEnergy, $enemyPositions, $walls, $myCores, $config);
} else {
// Scout - explore cautiously
$move = $this->computeScoutMove($bot, $enemyPositions, $walls, $config);
}
if ($move) {
$moves[] = $move;
$assignedPositions[$this->posKey($bot->position)] = true;
}
}
return $moves;
}
/**
* Assign bots to defend cores based on threat level
*/
private function assignDefenders(array $myBots, array $myCores, array $enemyBots, GameConfig $config): array {
$defenders = [];
if (empty($myCores)) {
return $defenders;
}
// Calculate threat level for each core
$coreThreats = [];
foreach ($myCores as $core) {
$threat = 0;
foreach ($enemyBots as $enemy) {
$dist2 = $enemy->position->distance2($core->position, $config->rows, $config->cols);
if ($dist2 <= 100) { // Within 10 tiles
$threat += 10 - (int)sqrt($dist2);
}
}
$coreThreats[$this->posKey($core->position)] = $threat;
}
// Assign bots to cores based on threat and proximity
foreach ($myBots as $bot) {
$bestCore = null;
$bestScore = PHP_INT_MAX;
foreach ($myCores as $core) {
$dist2 = $bot->position->distance2($core->position, $config->rows, $config->cols);
$threat = $coreThreats[$this->posKey($core->position)];
// Prioritize threatened cores
$score = $dist2 - $threat * 100;
if ($score < $bestScore) {
$bestScore = $score;
$bestCore = $core;
}
}
if ($bestCore) {
$defenders[$this->posKey($bot->position)] = $bestCore;
}
}
return $defenders;
}
/**
* Compute move for a defender bot
*/
private function computeDefenderMove(VisibleBot $bot, VisibleCore $core, array $enemyBots, array $walls, GameConfig $config): ?Move {
$rows = $config->rows;
$cols = $config->cols;
// Find nearest enemy within threat range
$nearestEnemy = null;
$nearestEnemyDist = PHP_INT_MAX;
foreach ($enemyBots as $enemy) {
$dist2 = $bot->position->distance2($enemy->position, $rows, $cols);
if ($dist2 < $nearestEnemyDist && $dist2 <= 100) {
$nearestEnemyDist = $dist2;
$nearestEnemy = $enemy;
}
}
// If enemy is approaching, intercept
if ($nearestEnemy && $nearestEnemyDist <= 50) {
$dir = $this->getDirectionToward($bot->position, $nearestEnemy->position, $walls, $config);
if ($dir) {
return new Move($bot->position, $dir);
}
}
// Otherwise, maintain perimeter around core
$distToCore = $bot->position->distance2($core->position, $rows, $cols);
if ($distToCore > self::PERIMETER_RADIUS * self::PERIMETER_RADIUS) {
// Move toward core
$dir = $this->getDirectionToward($bot->position, $core->position, $walls, $config);
if ($dir) {
return new Move($bot->position, $dir);
}
}
// Stay in place or patrol
return null;
}
/**
* Check if bot should gather energy
*/
private function shouldGather(VisibleBot $bot, array $myCores, GameConfig $config): bool {
foreach ($myCores as $core) {
$dist2 = $bot->position->distance2($core->position, $config->rows, $config->cols);
if ($dist2 <= self::SAFE_ZONE_RADIUS * self::SAFE_ZONE_RADIUS) {
return true;
}
}
return false;
}
/**
* Compute move for a gatherer bot
*/
private function computeGatherMove(VisibleBot $bot, array $energyPositions, array &$usedEnergy, array $enemyPositions, array $walls, array $myCores, GameConfig $config): ?Move {
// Find nearest untargeted energy within safe zone
$bestEnergy = null;
$bestDist = PHP_INT_MAX;
foreach ($energyPositions as $posKey => $pos) {
if (isset($usedEnergy[$posKey])) {
continue;
}
// Check if energy is within safe zone of any core
$inSafeZone = false;
foreach ($myCores as $core) {
$dist2 = $pos->distance2($core->position, $config->rows, $config->cols);
if ($dist2 <= self::SAFE_ZONE_RADIUS * self::SAFE_ZONE_RADIUS) {
$inSafeZone = true;
break;
}
}
if (!$inSafeZone) {
continue;
}
$dist2 = $bot->position->distance2($pos, $config->rows, $config->cols);
if ($dist2 < $bestDist) {
$bestDist = $dist2;
$bestEnergy = $pos;
}
}
if ($bestEnergy) {
$usedEnergy[$this->posKey($bestEnergy)] = true;
$dir = $this->getDirectionToward($bot->position, $bestEnergy, $walls, $config);
if ($dir) {
return new Move($bot->position, $dir);
}
}
return null;
}
/**
* Compute move for a scout bot
*/
private function computeScoutMove(VisibleBot $bot, array $enemyPositions, array $walls, GameConfig $config): ?Move {
// Move away from enemies if too close
foreach ($enemyPositions as $posKey => $pos) {
$dist2 = $bot->position->distance2($pos, $config->rows, $config->cols);
if ($dist2 <= $config->attackRadius2 + 4) {
$dir = $this->getDirectionAway($bot->position, $pos, $walls, $config);
if ($dir) {
return new Move($bot->position, $dir);
}
}
}
// Explore - move toward unexplored areas
$bestDir = null;
$bestScore = -1;
foreach (self::DIRECTIONS as $dir) {
$newPos = $bot->position->moveToward($dir, $config->rows, $config->cols);
$posKey = $this->posKey($newPos);
if (isset($walls[$posKey]) || isset($enemyPositions[$posKey])) {
continue;
}
// Prefer directions that move toward center of map (more exploration)
$centerRow = $config->rows / 2;
$centerCol = $config->cols / 2;
$distToCenter = abs($newPos->row - $centerRow) + abs($newPos->col - $centerCol);
// Prefer edges for exploration
$edgeDist = min($newPos->row, $newPos->col, $config->rows - $newPos->row, $config->cols - $newPos->col);
$score = $edgeDist < 10 ? 10 - $edgeDist : 0;
if ($score > $bestScore) {
$bestScore = $score;
$bestDir = $dir;
}
}
if ($bestDir) {
return new Move($bot->position, $bestDir);
}
return null;
}
/**
* Get direction toward a target position using simple greedy approach
*/
private function getDirectionToward(Position $from, Position $to, array $walls, GameConfig $config): ?string {
$rows = $config->rows;
$cols = $config->cols;
$bestDir = null;
$bestDist = PHP_INT_MAX;
foreach (self::DIRECTIONS as $dir) {
$newPos = $from->moveToward($dir, $rows, $cols);
if (isset($walls[$this->posKey($newPos)])) {
continue;
}
$dist2 = $newPos->distance2($to, $rows, $cols);
if ($dist2 < $bestDist) {
$bestDist = $dist2;
$bestDir = $dir;
}
}
return $bestDir;
}
/**
* Get direction away from a threat
*/
private function getDirectionAway(Position $from, Position $threat, array $walls, GameConfig $config): ?string {
$rows = $config->rows;
$cols = $config->cols;
$bestDir = null;
$bestDist = 0;
foreach (self::DIRECTIONS as $dir) {
$newPos = $from->moveToward($dir, $rows, $cols);
if (isset($walls[$this->posKey($newPos)])) {
continue;
}
$dist2 = $newPos->distance2($threat, $rows, $cols);
if ($dist2 > $bestDist) {
$bestDist = $dist2;
$bestDir = $dir;
}
}
return $bestDir;
}
/**
* Build a set of positions for O(1) lookup
*/
private function buildPositionSet(array $positions): array {
$set = [];
foreach ($positions as $pos) {
$set[$this->posKey($pos)] = $pos;
}
return $set;
}
/**
* Create a unique key for a position
*/
private function posKey(Position $pos): string {
return "{$pos->row},{$pos->col}";
}
}

View file

@ -0,0 +1,394 @@
package com.acb.hunter;
import java.util.*;
import java.util.stream.Collectors;
/**
* HunterBot strategy: target isolated enemies for efficient kills.
*
* Strategy: Target isolated enemy bots for efficient kills.
* - Identify enemy bots that are >=4 tiles from their nearest friendly bot (isolated targets)
* - Send pairs of bots to intercept isolated enemies (2v1 wins cleanly)
* - If no isolated targets, default to gatherer behavior
* - Maintain a map of known enemy positions across turns, predict movement
* - Avoid engaging formations of 3+ enemy bots
* - Opportunistic energy collection when not actively hunting
*/
public class HunterStrategy {
private static final int ISOLATION_THRESHOLD = 16; // Squared distance (4 tiles)
private static final int FORMATION_SIZE = 3; // Avoid groups of 3+ enemies
// Track known enemy positions for prediction
private final Map<String, EnemyTracker> enemyTrackers = new HashMap<>();
/**
* Compute moves for all owned bots
*/
public List<Move> computeMoves(GameState state) {
int myId = state.getYou().getId();
GameConfig config = state.getConfig();
int rows = config.getRows();
int cols = config.getCols();
// Separate my bots from enemies
List<VisibleBot> myBots = new ArrayList<>();
List<VisibleBot> 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();
}
// Update enemy trackers
updateEnemyTrackers(enemyBots, rows, cols);
// Build position lookups
Set<String> walls = buildPositionSet(state.getWalls());
Set<String> enemyPositions = buildPositionSet(
enemyBots.stream().map(VisibleBot::getPosition).collect(Collectors.toList())
);
Set<String> myBotPositions = buildPositionSet(
myBots.stream().map(VisibleBot::getPosition).collect(Collectors.toList())
);
// Find isolated enemy targets
List<VisibleBot> isolatedEnemies = findIsolatedEnemies(enemyBots, rows, cols);
// Find energy positions
Set<String> energyPositions = buildPositionSet(state.getEnergy());
// Assign bots to targets
List<Move> moves = new ArrayList<>();
Set<String> usedEnergy = new HashSet<>();
Set<Position> assignedTargets = new HashSet<>();
// First, assign hunters to isolated enemies
Map<VisibleBot, VisibleBot> hunterAssignments = assignHunters(myBots, isolatedEnemies, rows, cols);
for (Map.Entry<VisibleBot, VisibleBot> entry : hunterAssignments.entrySet()) {
VisibleBot hunter = entry.getKey();
VisibleBot target = entry.getValue();
// Get predicted position of target
Position predictedPos = predictPosition(target, rows, cols);
assignedTargets.add(predictedPos);
Move move = computeHunterMove(hunter, predictedPos, enemyPositions, walls, myBotPositions, rows, cols);
if (move != null) {
moves.add(move);
// Mark this bot as assigned
myBotPositions.remove(hunter.getPosition().key());
}
}
// Second, assign remaining bots to gather or explore
for (VisibleBot bot : myBots) {
if (!myBotPositions.contains(bot.getPosition().key())) {
continue; // Already assigned
}
Move move;
if (!energyPositions.isEmpty()) {
move = computeGatherMove(bot, energyPositions, usedEnergy, enemyPositions, walls, rows, cols);
} else {
move = computeExploreMove(bot, enemyPositions, walls, rows, cols);
}
if (move != null) {
moves.add(move);
}
}
return moves;
}
/**
* Update enemy position trackers for prediction
*/
private void updateEnemyTrackers(List<VisibleBot> enemyBots, int rows, int cols) {
for (VisibleBot bot : enemyBots) {
String key = bot.getPosition().key();
EnemyTracker tracker = enemyTrackers.computeIfAbsent(key, k -> new EnemyTracker());
tracker.update(bot.getPosition(), rows, cols);
}
}
/**
* Find isolated enemy bots (>=4 tiles from nearest friendly)
*/
private List<VisibleBot> findIsolatedEnemies(List<VisibleBot> enemyBots, int rows, int cols) {
List<VisibleBot> isolated = new ArrayList<>();
for (VisibleBot bot : enemyBots) {
boolean isIsolated = true;
int nearestDist = Integer.MAX_VALUE;
for (VisibleBot other : enemyBots) {
if (bot == other) continue;
int dist = bot.getPosition().distance2(other.getPosition(), rows, cols);
nearestDist = Math.min(nearestDist, dist);
}
// Isolated if nearest friendly is >= 4 tiles away (squared distance 16)
// or if it's the only enemy bot
if (nearestDist >= ISOLATION_THRESHOLD || enemyBots.size() == 1) {
isolated.add(bot);
}
}
return isolated;
}
/**
* Assign hunters to isolated targets using greedy matching
*/
private Map<VisibleBot, VisibleBot> assignHunters(
List<VisibleBot> myBots,
List<VisibleBot> isolatedEnemies,
int rows, int cols
) {
Map<VisibleBot, VisibleBot> assignments = new HashMap<>();
if (isolatedEnemies.isEmpty()) {
return assignments;
}
// Sort my bots by distance to nearest isolated enemy
List<VisibleBot> availableHunters = new ArrayList<>(myBots);
// Assign 2 hunters per target when possible
for (VisibleBot target : isolatedEnemies) {
int huntersNeeded = 2;
// Sort available hunters by distance to target
availableHunters.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<VisibleBot> iter = availableHunters.iterator();
while (iter.hasNext() && assigned < huntersNeeded) {
VisibleBot hunter = iter.next();
assignments.put(hunter, target);
iter.remove();
assigned++;
}
}
return assignments;
}
/**
* Predict where an enemy will be next turn
*/
private Position predictPosition(VisibleBot enemy, int rows, int cols) {
String key = enemy.getPosition().key();
EnemyTracker tracker = enemyTrackers.get(key);
if (tracker != null && tracker.hasPrediction()) {
return tracker.predictNextPosition(rows, cols);
}
return enemy.getPosition();
}
/**
* Compute move for a hunter bot toward a target
*/
private Move computeHunterMove(
VisibleBot bot,
Position target,
Set<String> enemyPositions,
Set<String> walls,
Set<String> myBotPositions,
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 newPosKey = newPos.key();
// Can't move into walls
if (walls.contains(newPosKey)) {
continue;
}
// Avoid self-collision
if (myBotPositions.contains(newPosKey)) {
continue;
}
// Score: prefer getting closer to target
int distToTarget = newPos.distance2(target, rows, cols);
int currentDistToTarget = bot.getPosition().distance2(target, rows, cols);
int score = currentDistToTarget - distToTarget;
// Bonus for being in attack range of target
if (distToTarget <= 5) { // attack_radius2
score += 20;
}
// Penalty for moving adjacent to multiple enemies
int adjacentEnemies = 0;
for (String enemyPosKey : enemyPositions) {
String[] parts = enemyPosKey.split(",");
Position enemyPos = new Position(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]));
if (newPos.distance2(enemyPos, rows, cols) <= 2) {
adjacentEnemies++;
}
}
score -= adjacentEnemies * 10;
if (score > bestScore) {
bestScore = score;
bestDir = dir;
}
}
if (bestDir != null) {
return new Move(bot.getPosition(), bestDir);
}
return null;
}
/**
* Compute move for a gatherer bot
*/
private Move computeGatherMove(
VisibleBot bot,
Set<String> energyPositions,
Set<String> usedEnergy,
Set<String> enemyPositions,
Set<String> walls,
int rows, int cols
) {
// Find nearest untargeted energy
Position nearestEnergy = null;
int nearestDist = Integer.MAX_VALUE;
for (String energyKey : energyPositions) {
if (usedEnergy.contains(energyKey)) continue;
String[] parts = energyKey.split(",");
Position energyPos = new Position(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]));
int dist = bot.getPosition().distance2(energyPos, rows, cols);
if (dist < nearestDist) {
nearestDist = dist;
nearestEnergy = energyPos;
}
}
if (nearestEnergy != null) {
usedEnergy.add(nearestEnergy.key());
return computeMoveToward(bot, nearestEnergy, walls, rows, cols);
}
return null;
}
/**
* Compute move for exploration
*/
private Move computeExploreMove(
VisibleBot bot,
Set<String> enemyPositions,
Set<String> walls,
int rows, int cols
) {
// Move toward center if no other target
Position center = new Position(rows / 2, cols / 2);
return computeMoveToward(bot, center, walls, rows, cols);
}
/**
* Compute move toward a target position
*/
private Move computeMoveToward(VisibleBot bot, Position target, Set<String> walls, int rows, int cols) {
Direction bestDir = null;
int bestDist = Integer.MAX_VALUE;
for (Direction dir : Direction.all()) {
Position newPos = bot.getPosition().moveToward(dir, rows, cols);
if (walls.contains(newPos.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;
}
/**
* Build a set of position keys for O(1) lookup
*/
private Set<String> buildPositionSet(List<Position> positions) {
return positions.stream()
.map(Position::key)
.collect(Collectors.toSet());
}
}
/**
* Tracks enemy position history for movement prediction
*/
class EnemyTracker {
private Position lastPosition;
private Position currentPosition;
private int sightings;
public void update(Position position, int rows, int cols) {
lastPosition = currentPosition;
currentPosition = position;
sightings++;
}
public boolean hasPrediction() {
return lastPosition != null && currentPosition != null;
}
public Position predictNextPosition(int rows, int cols) {
if (!hasPrediction()) {
return currentPosition;
}
// Simple prediction: continue in same direction
int dr = currentPosition.getRow() - lastPosition.getRow();
int dc = currentPosition.getCol() - lastPosition.getCol();
// Handle wrap
if (dr > rows / 2) dr -= rows;
if (dr < -rows / 2) dr += rows;
if (dc > cols / 2) dc -= cols;
if (dc < -cols / 2) dc += cols;
// Predict next position
int newRow = (currentPosition.getRow() + dr + rows) % rows;
int newCol = (currentPosition.getCol() + dc + cols) % cols;
return new Position(newRow, newCol);
}
}

View file

@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""
RandomBot - A bot that makes random valid moves.
This is a reference implementation demonstrating the HTTP protocol
in Python. It validates HMAC signatures and returns random moves.
"""
import hashlib
import hmac
import json
import os
import random
from http.server import HTTPServer, BaseHTTPRequestHandler
class GameState:
"""Represents the fog-filtered state visible to this bot."""
def __init__(self, data: dict):
self.match_id = data["match_id"]
self.turn = data["turn"]
self.config = data["config"]
self.you_id = data["you"]["id"]
self.you_energy = data["you"]["energy"]
self.you_score = data["you"]["score"]
self.bots = data["bots"]
self.energy = data.get("energy", [])
self.cores = data.get("cores", [])
self.walls = data.get("walls", [])
self.dead = data.get("dead", [])
class RandomBotHandler(BaseHTTPRequestHandler):
"""HTTP request handler for RandomBot."""
secret: str = ""
def log_message(self, format, *args):
"""Suppress default logging."""
pass
def send_json_response(self, status: int, data: dict, match_id: str = "", turn: int = 0):
"""Send a JSON response with HMAC signature."""
body = json.dumps(data).encode("utf-8")
# Sign response
sig = self.sign_response(body, match_id, turn)
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("X-ACB-Signature", sig)
self.end_headers()
self.wfile.write(body)
def sign_response(self, body: bytes, match_id: str, turn: int) -> str:
"""Generate HMAC signature for response."""
body_hash = hashlib.sha256(body).hexdigest()
signing_string = f"{match_id}.{turn}.{body_hash}"
sig = hmac.new(
self.secret.encode("utf-8"),
signing_string.encode("utf-8"),
hashlib.sha256
).hexdigest()
return sig
def verify_signature(self, body: bytes, match_id: str, turn: str,
timestamp: str, signature: str) -> bool:
"""Verify HMAC signature of incoming request."""
body_hash = hashlib.sha256(body).hexdigest()
signing_string = f"{match_id}.{turn}.{timestamp}.{body_hash}"
expected_sig = hmac.new(
self.secret.encode("utf-8"),
signing_string.encode("utf-8"),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected_sig)
def do_GET(self):
"""Handle GET requests (health check)."""
if self.path == "/health":
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"OK")
else:
self.send_error(404, "Not Found")
def do_POST(self):
"""Handle POST requests (turn)."""
if self.path != "/turn":
self.send_error(404, "Not Found")
return
# Read body
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
# Get auth headers
match_id = self.headers.get("X-ACB-Match-Id", "")
turn_str = self.headers.get("X-ACB-Turn", "0")
timestamp = self.headers.get("X-ACB-Timestamp", "")
signature = self.headers.get("X-ACB-Signature", "")
if not signature:
self.send_error(401, "Missing signature")
return
# Verify signature
if not self.verify_signature(body, match_id, turn_str, timestamp, signature):
self.send_error(401, "Invalid signature")
return
# Parse game state
try:
data = json.loads(body)
state = GameState(data)
except (json.JSONDecodeError, KeyError) as e:
self.send_error(400, f"Invalid game state: {e}")
return
# Compute random moves
moves = self.compute_moves(state)
turn = int(turn_str)
# Send response
self.send_json_response(200, {"moves": moves}, match_id, turn)
def compute_moves(self, state: GameState) -> list:
"""Compute random moves for all owned bots."""
moves = []
directions = ["N", "E", "S", "W"]
for bot in state.bots:
if bot["owner"] == state.you_id:
# 50% chance to move, 50% chance to stay still
if random.random() < 0.5:
direction = random.choice(directions)
moves.append({
"position": bot["position"],
"direction": direction
})
return moves
def main():
port = int(os.environ.get("BOT_PORT", "8081"))
secret = os.environ.get("BOT_SECRET", "")
if not secret:
print("ERROR: BOT_SECRET environment variable is required")
exit(1)
RandomBotHandler.secret = secret
server = HTTPServer(("", port), RandomBotHandler)
print(f"RandomBot starting on port {port}")
server.serve_forever()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,192 @@
//! RusherBot strategy: aggressive core-rushing behavior.
//!
//! This strategy identifies and rushes the nearest enemy core as fast as possible.
//! Bots use BFS to find paths to enemy cores, ignoring energy and enemy bots
//! unless they block the path.
use crate::game::{Direction, GameConfig, GameState, Move, Position, VisibleBot, VisibleCore};
use std::collections::{HashMap, HashSet, VecDeque};
/// RusherStrategy implements aggressive core-rushing behavior.
pub struct RusherStrategy {
/// Known enemy core positions (discovered during gameplay)
known_enemy_cores: HashSet<Position>,
}
impl RusherStrategy {
pub fn new() -> Self {
Self {
known_enemy_cores: HashSet::new(),
}
}
/// Compute moves for all owned bots
pub fn compute_moves(&mut self, state: &GameState) -> Vec<Move> {
let my_id = state.you.id;
let config = &state.config;
// Update known enemy cores
self.update_known_cores(state, my_id);
// Separate my bots from enemies
let (my_bots, enemy_bots): (Vec<_>, Vec<_>) =
state.bots.iter().partition(|b| b.owner == my_id);
if my_bots.is_empty() {
return vec![];
}
// Build position lookup for enemies
let enemy_positions: HashSet<Position> =
enemy_bots.iter().map(|b| b.position).collect();
// Build wall lookup
let walls: HashSet<Position> = state.walls.iter().copied().collect();
// Find target cores to rush
let targets = self.get_rush_targets(state, my_id);
// Assign each bot to the nearest target
let mut moves = Vec::with_capacity(my_bots.len());
let mut assigned_targets: HashSet<Position> = HashSet::new();
for bot in &my_bots {
if let Some((dir, _)) = self.find_best_move(
bot.position,
&targets,
&enemy_positions,
&walls,
&assigned_targets,
config,
) {
// Mark target as assigned to avoid duplicates
if let Some(target) = self.find_target_for_bot(bot.position, &targets, config) {
assigned_targets.insert(target);
}
moves.push(Move {
position: bot.position,
direction: dir,
});
}
}
moves
}
/// Update known enemy cores from visible state
fn update_known_cores(&mut self, state: &GameState, my_id: u32) {
for core in &state.cores {
if core.owner != my_id {
self.known_enemy_cores.insert(core.position);
}
}
}
/// Get list of cores to rush (enemy cores first, then explore)
fn get_rush_targets(&self, state: &GameState, my_id: u32) -> Vec<Position> {
let mut targets: Vec<Position> = state
.cores
.iter()
.filter(|c| c.owner != my_id && c.active)
.map(|c| c.position)
.collect();
// If we know about enemy cores from previous turns, include them
for pos in &self.known_enemy_cores {
if !targets.contains(pos) {
targets.push(*pos);
}
}
// If no enemy cores known, explore the map
if targets.is_empty() {
// Add exploration targets at grid edges
let rows = state.config.rows as i32;
let cols = state.config.cols as i32;
targets.push(Position { row: rows / 2, col: cols / 2 });
targets.push(Position { row: 0, col: 0 });
targets.push(Position { row: rows - 1, col: cols - 1 });
}
targets
}
/// Find the best move for a bot using BFS toward targets
fn find_best_move(
&self,
start: Position,
targets: &[Position],
enemy_positions: &HashSet<Position>,
walls: &HashSet<Position>,
_assigned_targets: &HashSet<Position>,
config: &GameConfig,
) -> Option<(Direction, Position)> {
let rows = config.rows as i32;
let cols = config.cols as i32;
// BFS to find shortest path to any target
let mut visited: HashSet<Position> = HashSet::new();
let mut queue: VecDeque<(Position, Option<Direction>)> = VecDeque::new();
visited.insert(start);
queue.push_back((start, None));
while let Some((pos, first_dir)) = queue.pop_front() {
// Check if we've reached a target
if targets.contains(&pos) {
if let Some(dir) = first_dir {
return Some((dir, pos));
}
}
// Explore neighbors
for dir in Direction::all() {
let next = pos.move_toward(dir, rows, cols);
if visited.contains(&next) || walls.contains(&next) {
continue;
}
// Don't walk into enemy bots (but allow pathing near them)
if enemy_positions.contains(&next) {
continue;
}
visited.insert(next);
queue.push_back((next, first_dir.or(Some(dir))));
}
}
// No path found - pick a random direction
for dir in Direction::all() {
let next = start.move_toward(dir, rows, cols);
if !walls.contains(&next) && !enemy_positions.contains(&next) {
return Some((dir, next));
}
}
None
}
/// Find the nearest target for a bot
fn find_target_for_bot(
&self,
bot_pos: Position,
targets: &[Position],
config: &GameConfig,
) -> Option<Position> {
let rows = config.rows as i32;
let cols = config.cols as i32;
targets
.iter()
.min_by_key(|t| bot_pos.distance2(t, rows, cols))
.copied()
}
}
impl Default for RusherStrategy {
fn default() -> Self {
Self::new()
}
}

View file

@ -0,0 +1,228 @@
/**
* SwarmBot strategy: formation-based combat with tight cohesion.
*
* Strategy: Keep units in tight formations, advance as a group toward enemies.
* - All bots maintain cohesion — no bot moves if it would be >3 tiles from the
* nearest friendly bot
* - The swarm moves as a unit toward the nearest enemy presence
* - BFS-based center-of-mass steering
* - Energy collection is incidental (pass over it during advance)
* - New spawns rally to the swarm before advancing
*/
import {
GameState,
VisibleBot,
Position,
Move,
Direction,
GameConfig,
posKey,
posEquals,
moveToward,
distance2,
manhattanDistance,
ALL_DIRECTIONS,
buildPositionSet,
} from './game.js';
const COHESION_RADIUS = 3; // Maximum distance from nearest friendly
const COHESION_RADIUS2 = COHESION_RADIUS * COHESION_RADIUS;
export class SwarmStrategy {
/**
* Compute moves for all owned bots
*/
computeMoves(state: GameState): Move[] {
const myId = state.you.id;
const config = state.config;
// Separate my bots from enemies
const myBots: VisibleBot[] = [];
const enemyBots: VisibleBot[] = [];
for (const bot of state.bots) {
if (bot.owner === myId) {
myBots.push(bot);
} else {
enemyBots.push(bot);
}
}
if (myBots.length === 0) {
return [];
}
// Build wall lookup
const walls = buildPositionSet(state.walls);
// Build enemy position lookup
const enemyPositions = new Map<string, VisibleBot>();
for (const bot of enemyBots) {
enemyPositions.set(posKey(bot.position), bot);
}
// Calculate swarm center (center of mass of my bots)
const swarmCenter = this.calculateCenter(myBots.map(b => b.position), config);
// Calculate enemy center if any enemies visible
const enemyCenter = enemyBots.length > 0
? this.calculateCenter(enemyBots.map(b => b.position), config)
: null;
// My bot positions for cohesion checks
const myBotPositions = new Set(myBots.map(b => posKey(b.position)));
const moves: Move[] = [];
for (const bot of myBots) {
const move = this.computeBotMove(
bot,
myBotPositions,
enemyPositions,
swarmCenter,
enemyCenter,
walls,
config
);
if (move) {
moves.push(move);
}
}
return moves;
}
/**
* Calculate center of mass of positions
*/
private calculateCenter(positions: Position[], config: GameConfig): Position {
if (positions.length === 0) {
return { row: config.rows / 2, col: config.cols / 2 };
}
// Use circular mean for toroidal coordinates
let sumSinRow = 0, sumCosRow = 0;
let sumSinCol = 0, sumCosCol = 0;
const rowScale = (2 * Math.PI) / config.rows;
const colScale = (2 * Math.PI) / config.cols;
for (const pos of positions) {
sumSinRow += Math.sin(pos.row * rowScale);
sumCosRow += Math.cos(pos.row * rowScale);
sumSinCol += Math.sin(pos.col * colScale);
sumCosCol += Math.cos(pos.col * colScale);
}
const avgRow = Math.atan2(sumSinRow / positions.length, sumCosRow / positions.length) / rowScale;
const avgCol = Math.atan2(sumSinCol / positions.length, sumCosCol / positions.length) / colScale;
return {
row: ((avgRow % config.rows) + config.rows) % config.rows,
col: ((avgCol % config.cols) + config.cols) % config.cols,
};
}
/**
* Compute move for a single bot
*/
private computeBotMove(
bot: VisibleBot,
myBotPositions: Set<string>,
enemyPositions: Map<string, VisibleBot>,
swarmCenter: Position,
enemyCenter: Position | null,
walls: Set<string>,
config: GameConfig
): Move | null {
const rows = config.rows;
const cols = config.cols;
// Find direction that maintains cohesion while advancing toward enemy
let bestDir: Direction | null = null;
let bestScore = -Infinity;
// Target is enemy center if visible, otherwise explore
const target = enemyCenter ?? { row: rows / 2, col: cols / 2 };
for (const dir of ALL_DIRECTIONS) {
const newPos = moveToward(bot.position, dir, rows, cols);
const newPosKey = posKey(newPos);
// Can't move into walls or enemies
if (walls.has(newPosKey) || enemyPositions.has(newPosKey)) {
continue;
}
// Check cohesion: must stay within COHESION_RADIUS of at least one friendly bot
if (!this.maintainsCohesion(newPos, bot.position, myBotPositions, rows, cols)) {
continue;
}
// Score this move
let score = 0;
// Prefer moving toward enemy center (or target)
const distToTarget = distance2(newPos, target, rows, cols);
const currentDistToTarget = distance2(bot.position, target, rows, cols);
score += (currentDistToTarget - distToTarget) * 10; // Reward getting closer
// Prefer staying near swarm center
const distToSwarmCenter = distance2(newPos, swarmCenter, rows, cols);
score -= distToSwarmCenter * 0.5; // Penalize being far from swarm
// Bonus for moving toward nearby enemies (engagement)
let nearestEnemyDist = Infinity;
for (const enemy of enemyPositions.values()) {
const dist = distance2(newPos, enemy.position, rows, cols);
nearestEnemyDist = Math.min(nearestEnemyDist, dist);
}
if (nearestEnemyDist < Infinity) {
// Bonus for being in attack range
if (nearestEnemyDist <= config.attack_radius2) {
score += 50;
}
}
if (score > bestScore) {
bestScore = score;
bestDir = dir;
}
}
if (bestDir) {
return { position: bot.position, direction: bestDir };
}
// If no good move found, try to stay put or move toward swarm
return null;
}
/**
* Check if moving to newPos maintains cohesion with friendly bots
*/
private maintainsCohesion(
newPos: Position,
oldPos: Position,
myBotPositions: Set<string>,
rows: number,
cols: number
): boolean {
// Temporarily remove old position and add new position
const oldKey = posKey(oldPos);
for (const botPosKey of myBotPositions) {
if (botPosKey === oldKey) continue;
const [row, col] = botPosKey.split(',').map(Number);
const botPos = { row, col };
const dist2 = distance2(newPos, botPos, rows, cols);
if (dist2 <= COHESION_RADIUS2) {
return true;
}
}
return false;
}
}

View file

@ -0,0 +1,112 @@
package db
import (
"context"
"fmt"
"time"
)
// ValidationLog records the outcome of one validation pipeline run.
// It is written after every candidate evaluation so pass rates can be
// computed per island and per language.
type ValidationLog struct {
ID int64
Island string // one of IslandAlpha … IslandDelta
Language string // e.g. "go", "python"
Stage string // last stage attempted: "syntax", "schema", or "sandbox"
Passed bool // true when all stages up to (and including) Stage passed
ErrorText string // human-readable failure reason (empty on pass)
LLMOutput string // raw LLM response, for retry / learning
CreatedAt time.Time
}
// RecordValidation inserts one ValidationLog row.
func (s *Store) RecordValidation(ctx context.Context, v *ValidationLog) error {
_, err := s.db.ExecContext(ctx, `
INSERT INTO validation_log (island, language, stage, passed, error_text, llm_output)
VALUES ($1, $2, $3, $4, $5, $6)`,
v.Island, v.Language, v.Stage, v.Passed, v.ErrorText, v.LLMOutput,
)
if err != nil {
return fmt.Errorf("record validation: %w", err)
}
return nil
}
// IslandPassRates returns the validation pass rate (0.01.0) keyed by island
// name. Islands with no records are omitted from the result.
func (s *Store) IslandPassRates(ctx context.Context) (map[string]float64, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT island,
SUM(CASE WHEN passed THEN 1 ELSE 0 END)::float
/ NULLIF(COUNT(*), 0) AS pass_rate
FROM validation_log
GROUP BY island
`)
if err != nil {
return nil, fmt.Errorf("island pass rates: %w", err)
}
defer rows.Close()
result := make(map[string]float64)
for rows.Next() {
var island string
var rate float64
if err := rows.Scan(&island, &rate); err != nil {
return nil, fmt.Errorf("scan pass rate: %w", err)
}
result[island] = rate
}
return result, rows.Err()
}
// ValidationStats holds aggregate metrics for one island.
type ValidationStats struct {
Island string
Total int
Passed int
PassRate float64
ByStage map[string]int // count of runs that FAILED at each stage
}
// IslandValidationStats returns per-island validation statistics including
// breakdown by failure stage. Islands with no rows are not returned.
func (s *Store) IslandValidationStats(ctx context.Context) ([]ValidationStats, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT island,
COUNT(*) AS total,
SUM(CASE WHEN passed THEN 1 ELSE 0 END) AS passed_count,
SUM(CASE WHEN NOT passed AND stage = 'syntax' THEN 1 ELSE 0 END) AS fail_syntax,
SUM(CASE WHEN NOT passed AND stage = 'schema' THEN 1 ELSE 0 END) AS fail_schema,
SUM(CASE WHEN NOT passed AND stage = 'sandbox' THEN 1 ELSE 0 END) AS fail_sandbox
FROM validation_log
GROUP BY island
ORDER BY island
`)
if err != nil {
return nil, fmt.Errorf("validation stats: %w", err)
}
defer rows.Close()
var out []ValidationStats
for rows.Next() {
var v ValidationStats
var failSyntax, failSchema, failSandbox int
if err := rows.Scan(
&v.Island, &v.Total, &v.Passed,
&failSyntax, &failSchema, &failSandbox,
); err != nil {
return nil, fmt.Errorf("scan validation stats: %w", err)
}
if v.Total > 0 {
v.PassRate = float64(v.Passed) / float64(v.Total)
}
v.ByStage = map[string]int{
"syntax": failSyntax,
"schema": failSchema,
"sandbox": failSandbox,
}
out = append(out, v)
}
return out, rows.Err()
}

View file

@ -0,0 +1,115 @@
// Package mapelites implements a 2-D MAP-Elites behavior grid for diversity
// maintenance in the evolution pipeline.
//
// The two behavior dimensions are:
//
// X axis aggression (0.0 = pacifist … 1.0 = full aggressor)
// Y axis economy (0.0 = ignores energy … 1.0 = perfect economy)
//
// Each cell in the Size×Size grid holds the ID and fitness of the single best
// program discovered in that behavioral niche.
package mapelites
import "math"
// Grid is a 2-D MAP-Elites behavior grid.
type Grid struct {
size int
cells [][]Cell
}
// Cell is a single niche in the grid.
type Cell struct {
ProgramID int64
Fitness float64
Occupied bool
}
// Placement records which grid cell a program was placed into.
type Placement struct {
X, Y int
}
// New creates an empty Grid with the given side length.
func New(size int) *Grid {
cells := make([][]Cell, size)
for i := range cells {
cells[i] = make([]Cell, size)
}
return &Grid{size: size, cells: cells}
}
// BehaviorToCell converts continuous behavior values (each in [0, 1]) to
// discrete grid coordinates clamped to [0, size-1].
func (g *Grid) BehaviorToCell(aggression, economy float64) (x, y int) {
x = int(math.Min(math.Floor(aggression*float64(g.size)), float64(g.size-1)))
y = int(math.Min(math.Floor(economy*float64(g.size)), float64(g.size-1)))
return
}
// TryPlace attempts to place a program in the cell determined by its behavior
// vector. The cell is updated only when it is empty or the new program has
// strictly higher fitness than the incumbent.
// Returns the target cell coordinates and whether the cell was updated.
func (g *Grid) TryPlace(id int64, fitness, aggression, economy float64) (Placement, bool) {
x, y := g.BehaviorToCell(aggression, economy)
cell := &g.cells[x][y]
if !cell.Occupied || fitness > cell.Fitness {
*cell = Cell{ProgramID: id, Fitness: fitness, Occupied: true}
return Placement{X: x, Y: y}, true
}
return Placement{X: x, Y: y}, false
}
// Get returns the cell at grid coordinates (x, y).
func (g *Grid) Get(x, y int) Cell {
return g.cells[x][y]
}
// Size returns the side length of the grid.
func (g *Grid) Size() int {
return g.size
}
// OccupiedCount returns the number of filled cells.
func (g *Grid) OccupiedCount() int {
n := 0
for _, row := range g.cells {
for _, c := range row {
if c.Occupied {
n++
}
}
}
return n
}
// Elite returns the cell with the highest fitness in the grid.
// Returns (zero Cell, false) when the grid is empty.
func (g *Grid) Elite() (Cell, bool) {
var best Cell
found := false
for _, row := range g.cells {
for _, c := range row {
if c.Occupied && (!found || c.Fitness > best.Fitness) {
best = c
found = true
}
}
}
return best, found
}
// AllElites returns a flat slice of every occupied cell.
func (g *Grid) AllElites() []Cell {
var out []Cell
for _, row := range g.cells {
for _, c := range row {
if c.Occupied {
out = append(out, c)
}
}
}
return out
}

View file

@ -0,0 +1,170 @@
package mapelites
import "testing"
func TestBehaviorToCell(t *testing.T) {
g := New(10)
cases := []struct {
agg, eco float64
wantX, wantY int
}{
{0.0, 0.0, 0, 0},
{1.0, 1.0, 9, 9},
{0.5, 0.5, 5, 5},
{0.15, 0.85, 1, 8},
{0.99, 0.01, 9, 0},
{0.09, 0.09, 0, 0},
{0.1, 0.9, 1, 9},
}
for _, tc := range cases {
x, y := g.BehaviorToCell(tc.agg, tc.eco)
if x != tc.wantX || y != tc.wantY {
t.Errorf("BehaviorToCell(%.2f, %.2f) = (%d, %d), want (%d, %d)",
tc.agg, tc.eco, x, y, tc.wantX, tc.wantY)
}
}
}
func TestTryPlace_EmptyCell(t *testing.T) {
g := New(10)
p, placed := g.TryPlace(1, 10.0, 0.1, 0.9)
if !placed {
t.Fatal("expected placement into empty cell")
}
if p.X != 1 || p.Y != 9 {
t.Errorf("expected cell (1,9), got (%d,%d)", p.X, p.Y)
}
cell := g.Get(1, 9)
if !cell.Occupied || cell.ProgramID != 1 || cell.Fitness != 10.0 {
t.Errorf("unexpected cell state: %+v", cell)
}
}
func TestTryPlace_LowerFitnessDoesNotReplace(t *testing.T) {
g := New(10)
g.TryPlace(1, 10.0, 0.5, 0.5)
_, placed := g.TryPlace(2, 5.0, 0.5, 0.5)
if placed {
t.Fatal("lower fitness should not replace incumbent")
}
if g.Get(5, 5).ProgramID != 1 {
t.Error("incumbent program 1 should still hold the cell")
}
}
func TestTryPlace_HigherFitnessReplaces(t *testing.T) {
g := New(10)
g.TryPlace(1, 10.0, 0.5, 0.5)
_, placed := g.TryPlace(2, 20.0, 0.5, 0.5)
if !placed {
t.Fatal("higher fitness should replace incumbent")
}
cell := g.Get(5, 5)
if cell.ProgramID != 2 || cell.Fitness != 20.0 {
t.Errorf("expected program 2 with fitness 20, got %+v", cell)
}
}
func TestTryPlace_EqualFitnessDoesNotReplace(t *testing.T) {
g := New(10)
g.TryPlace(1, 10.0, 0.5, 0.5)
_, placed := g.TryPlace(2, 10.0, 0.5, 0.5)
if placed {
t.Fatal("equal fitness should not replace incumbent")
}
}
func TestOccupiedCount(t *testing.T) {
g := New(10)
if g.OccupiedCount() != 0 {
t.Error("new grid should have 0 occupied cells")
}
g.TryPlace(1, 1.0, 0.1, 0.1)
g.TryPlace(2, 1.0, 0.9, 0.9)
g.TryPlace(3, 1.0, 0.5, 0.5)
if g.OccupiedCount() != 3 {
t.Errorf("expected 3 occupied cells, got %d", g.OccupiedCount())
}
// Same cell should not increase count
g.TryPlace(4, 99.0, 0.5, 0.5)
if g.OccupiedCount() != 3 {
t.Errorf("expected still 3 occupied cells after same-cell update, got %d", g.OccupiedCount())
}
}
func TestElite_EmptyGrid(t *testing.T) {
g := New(10)
_, found := g.Elite()
if found {
t.Fatal("empty grid should have no elite")
}
}
func TestElite(t *testing.T) {
g := New(10)
g.TryPlace(1, 5.0, 0.1, 0.1)
g.TryPlace(2, 15.0, 0.9, 0.9)
g.TryPlace(3, 10.0, 0.5, 0.5)
elite, found := g.Elite()
if !found {
t.Fatal("expected an elite in non-empty grid")
}
if elite.ProgramID != 2 || elite.Fitness != 15.0 {
t.Errorf("expected elite program 2 (fitness 15), got %+v", elite)
}
}
func TestAllElites(t *testing.T) {
g := New(10)
if len(g.AllElites()) != 0 {
t.Error("empty grid should return no elites")
}
g.TryPlace(1, 1.0, 0.0, 0.0)
g.TryPlace(2, 2.0, 0.5, 0.5)
g.TryPlace(3, 3.0, 1.0, 1.0)
elites := g.AllElites()
if len(elites) != 3 {
t.Errorf("expected 3 elites, got %d", len(elites))
}
}
func TestSeedBehaviorVectors(t *testing.T) {
// Verify that the 6 seed bots land in distinct grid cells on a 10x10 grid.
g := New(10)
bots := []struct {
id int64
name string
aggression float64
economy float64
}{
{1, "gatherer", 0.1, 0.9},
{2, "guardian", 0.2, 0.6},
{3, "rusher", 0.9, 0.2},
{4, "swarm", 0.6, 0.5},
{5, "hunter", 0.7, 0.3},
{6, "random", 0.3, 0.4},
}
placed := 0
for _, b := range bots {
_, ok := g.TryPlace(b.id, 1.0, b.aggression, b.economy)
if ok {
placed++
}
}
if placed != 6 {
t.Errorf("expected all 6 seed bots in distinct cells, but only %d placed", placed)
}
if g.OccupiedCount() != 6 {
t.Errorf("expected 6 occupied cells, got %d", g.OccupiedCount())
}
}

View file

@ -0,0 +1,420 @@
package validator
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"time"
)
const (
// smokeMatchID and smokeSecret are fixed values used only during smoke tests.
smokeMatchID = "smoke-test-match"
smokeSecret = "smoke-test-secret-for-validation"
smokeBotID = "b_smoketest"
// healthPollInterval is how often we ping /health while waiting for startup.
healthPollInterval = 200 * time.Millisecond
// healthTimeout is how long we wait for the bot to become healthy.
healthStartupTimeout = 20 * time.Second
)
// RunSmokeTest builds and starts the bot in an isolated sandbox, then sends
// cfg.SmokeRequests test /turn requests and verifies all responses are valid.
//
// When nsjail is found in PATH (and cfg.UseNsjail is true), it wraps the bot
// process for CPU/memory resource limits. Otherwise it runs the bot directly
// with a context deadline.
func RunSmokeTest(ctx context.Context, code, language string, cfg Config) error {
ctx, cancel := context.WithTimeout(ctx, cfg.SandboxTimeout)
defer cancel()
dir, err := os.MkdirTemp("", "acb-sandbox-*")
if err != nil {
return fmt.Errorf("mkdirtemp: %w", err)
}
defer os.RemoveAll(dir)
// Build / prepare the bot.
execPath, execArgs, err := buildBot(ctx, code, language, dir)
if err != nil {
return fmt.Errorf("build: %w", err)
}
// Allocate a free port so the bot can be reached from this process.
port, err := freePort()
if err != nil {
return fmt.Errorf("allocate port: %w", err)
}
addr := fmt.Sprintf("127.0.0.1:%d", port)
// Compose the bot's environment.
env := append(os.Environ(),
fmt.Sprintf("BOT_PORT=%d", port),
"BOT_SECRET="+smokeSecret,
)
// Construct the run command, optionally wrapped in nsjail.
cmd := makeBotCmd(ctx, execPath, execArgs, dir, env, cfg)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return fmt.Errorf("start bot: %w", err)
}
defer func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
_ = cmd.Wait()
}
}()
// Wait for the /health endpoint to respond before sending turn requests.
if err := waitForHealth(ctx, addr, healthStartupTimeout); err != nil {
diag := truncate(stderr.String(), 256)
return fmt.Errorf("health startup timeout: %w (stderr: %s)", err, diag)
}
// Fire cfg.SmokeRequests test requests.
client := &http.Client{Timeout: 5 * time.Second}
for i := 1; i <= cfg.SmokeRequests; i++ {
if err := sendTurnRequest(ctx, client, addr, i); err != nil {
return fmt.Errorf("smoke request %d/%d failed: %w", i, cfg.SmokeRequests, err)
}
}
return nil
}
// makeBotCmd returns an *exec.Cmd that runs the bot, optionally wrapped in
// nsjail when it is available and cfg.UseNsjail is true.
func makeBotCmd(ctx context.Context, execPath string, execArgs []string, dir string, env []string, cfg Config) *exec.Cmd {
if cfg.UseNsjail {
if nsjailBin, err := exec.LookPath(cfg.NsjailPath); err == nil {
return buildNsjailCmd(ctx, nsjailBin, execPath, execArgs, dir, env)
}
}
// Plain exec fallback.
cmd := exec.CommandContext(ctx, execPath, execArgs...)
cmd.Env = env
cmd.Dir = dir
return cmd
}
// buildNsjailCmd wraps execPath in nsjail for CPU/memory resource limiting.
// Network isolation is not applied so the bot can bind its HTTP port and
// receive requests from the test loop running in the same network namespace.
func buildNsjailCmd(ctx context.Context, nsjailBin, execPath string, execArgs []string, dir string, env []string) *exec.Cmd {
args := []string{
"--mode", "o", // single-shot: run one command then exit
"--time_limit", "30", // 30-second wall-clock limit
"--rlimit_as", "512", // 512 MiB virtual address space
"--rlimit_cpu", "15", // 15 CPU seconds
"--rlimit_nofile", "64",
"--chdir", dir,
"--bindmount", dir, // bot workspace, read-write
}
// Read-only bind-mounts for language runtimes and system libraries.
for _, p := range []string{"/bin", "/usr", "/lib", "/lib64", "/etc/alternatives", "/proc", "/dev"} {
if _, err := os.Stat(p); err == nil {
args = append(args, "--bindmount_ro", p)
}
}
args = append(args, "--tmpfsmount", "/tmp")
args = append(args, "--")
args = append(args, execPath)
args = append(args, execArgs...)
cmd := exec.CommandContext(ctx, nsjailBin, args...)
cmd.Env = env
cmd.Dir = dir
return cmd
}
// buildBot writes the bot source to dir, compiles it where necessary, and
// returns the executable path plus any arguments needed to run it.
func buildBot(ctx context.Context, code, language, dir string) (string, []string, error) {
switch language {
case "go":
return buildGo(ctx, code, dir)
case "python":
src := filepath.Join(dir, "bot.py")
if err := os.WriteFile(src, []byte(code), 0o600); err != nil {
return "", nil, err
}
return "python3", []string{src}, nil
case "rust":
return buildRust(ctx, code, dir)
case "typescript":
return buildTypeScript(ctx, code, dir)
case "java":
return buildJava(ctx, code, dir)
case "php":
src := filepath.Join(dir, "bot.php")
if err := os.WriteFile(src, []byte(code), 0o600); err != nil {
return "", nil, err
}
return "php", []string{src}, nil
default:
return "", nil, fmt.Errorf("unsupported language: %s", language)
}
}
func buildGo(ctx context.Context, code, dir string) (string, []string, error) {
if err := os.WriteFile(filepath.Join(dir, "bot.go"), []byte(code), 0o600); err != nil {
return "", nil, err
}
// Minimal go.mod so `go build` works outside a workspace.
gomod := "module bot\n\ngo 1.21\n"
if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(gomod), 0o600); err != nil {
return "", nil, err
}
binPath := filepath.Join(dir, "bot")
cmd := exec.CommandContext(ctx, "go", "build", "-o", binPath, ".")
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
return "", nil, fmt.Errorf("go build: %s", truncate(string(out), 512))
}
return binPath, nil, nil
}
func buildRust(ctx context.Context, code, dir string) (string, []string, error) {
src := filepath.Join(dir, "main.rs")
if err := os.WriteFile(src, []byte(code), 0o600); err != nil {
return "", nil, err
}
binPath := filepath.Join(dir, "bot")
cmd := exec.CommandContext(ctx, "rustc", "--edition", "2021", src, "-o", binPath)
if out, err := cmd.CombinedOutput(); err != nil {
return "", nil, fmt.Errorf("rustc: %s", truncate(string(out), 512))
}
return binPath, nil, nil
}
func buildTypeScript(ctx context.Context, code, dir string) (string, []string, error) {
if err := os.WriteFile(filepath.Join(dir, "bot.ts"), []byte(code), 0o600); err != nil {
return "", nil, err
}
tsconfig := `{"compilerOptions":{"target":"ES2020","module":"commonjs","outDir":"./"},"files":["bot.ts"]}`
if err := os.WriteFile(filepath.Join(dir, "tsconfig.json"), []byte(tsconfig), 0o600); err != nil {
return "", nil, err
}
cmd := exec.CommandContext(ctx, "tsc", "--project", filepath.Join(dir, "tsconfig.json"))
if out, err := cmd.CombinedOutput(); err != nil {
return "", nil, fmt.Errorf("tsc: %s", truncate(string(out), 512))
}
return "node", []string{filepath.Join(dir, "bot.js")}, nil
}
func buildJava(ctx context.Context, code, dir string) (string, []string, error) {
className := extractJavaPublicClass(code)
if className == "" {
className = "Bot"
}
src := filepath.Join(dir, className+".java")
if err := os.WriteFile(src, []byte(code), 0o600); err != nil {
return "", nil, err
}
cmd := exec.CommandContext(ctx, "javac", src)
if out, err := cmd.CombinedOutput(); err != nil {
return "", nil, fmt.Errorf("javac: %s", truncate(string(out), 512))
}
return "java", []string{"-cp", dir, className}, nil
}
// freePort returns an unused TCP port on localhost.
func freePort() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
port := l.Addr().(*net.TCPAddr).Port
l.Close()
return port, nil
}
// waitForHealth polls GET /health until it returns 200 or timeout elapses.
func waitForHealth(ctx context.Context, addr string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
client := &http.Client{Timeout: 500 * time.Millisecond}
for time.Now().Before(deadline) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+addr+"/health", nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return nil
}
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(healthPollInterval):
}
}
return fmt.Errorf("bot did not become healthy within %s", timeout)
}
// sendTurnRequest sends one POST /turn request to the bot and validates the
// JSON response.
func sendTurnRequest(ctx context.Context, client *http.Client, addr string, turn int) error {
state := makeTestState(turn)
body, err := json.Marshal(state)
if err != nil {
return fmt.Errorf("marshal state: %w", err)
}
sig := signSmokeRequest(smokeSecret, smokeMatchID, turn, body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"http://"+addr+"/turn", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-ACB-Match-Id", smokeMatchID)
req.Header.Set("X-ACB-Turn", strconv.Itoa(turn))
req.Header.Set("X-ACB-Timestamp", strconv.FormatInt(time.Now().Unix(), 10))
req.Header.Set("X-ACB-Bot-Id", smokeBotID)
req.Header.Set("X-ACB-Signature", sig)
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bot returned HTTP %d", resp.StatusCode)
}
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return fmt.Errorf("read response: %w", err)
}
return validateMoveResponse(respBody)
}
// signSmokeRequest computes the HMAC-SHA256 signature used by reference bot
// implementations. The signing string matches the format in bots/*/main.go:
//
// "{match_id}.{turn}.{sha256hex(body)}"
//
// Note: this format does NOT include a timestamp, matching the reference bots
// (bots/gatherer, bots/rusher, etc.) that LLM candidates are shown as templates.
func signSmokeRequest(secret, matchID string, turn int, body []byte) string {
bodyHash := sha256.Sum256(body)
msg := fmt.Sprintf("%s.%d.%s", matchID, turn, hex.EncodeToString(bodyHash[:]))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(msg))
return hex.EncodeToString(mac.Sum(nil))
}
// ── Test state types ──────────────────────────────────────────────────────
type smokeState struct {
MatchID string `json:"match_id"`
Turn int `json:"turn"`
Config smokeConfig `json:"config"`
You smokePlayer `json:"you"`
Bots []smokeBot `json:"bots"`
Energy []smokePos `json:"energy"`
Cores []smokeCore `json:"cores"`
Walls []smokePos `json:"walls"`
Dead []smokeBot `json:"dead"`
}
type smokeConfig struct {
Rows int `json:"rows"`
Cols int `json:"cols"`
MaxTurns int `json:"max_turns"`
VisionRadius2 int `json:"vision_radius2"`
AttackRadius2 int `json:"attack_radius2"`
SpawnCost int `json:"spawn_cost"`
EnergyInterval int `json:"energy_interval"`
}
type smokePlayer struct {
ID int `json:"id"`
Energy int `json:"energy"`
Score int `json:"score"`
}
type smokeBot struct {
Position smokePos `json:"position"`
Owner int `json:"owner"`
}
type smokePos struct {
Row int `json:"row"`
Col int `json:"col"`
}
type smokeCore struct {
Position smokePos `json:"position"`
Owner int `json:"owner"`
Active bool `json:"active"`
}
// makeTestState returns a minimal, syntactically valid game state for smoke testing.
func makeTestState(turn int) smokeState {
return smokeState{
MatchID: smokeMatchID,
Turn: turn,
Config: smokeConfig{
Rows: 60, Cols: 60,
MaxTurns: 500, VisionRadius2: 49,
AttackRadius2: 1, SpawnCost: 3,
EnergyInterval: 10,
},
You: smokePlayer{ID: 1, Energy: 10, Score: 0},
Bots: []smokeBot{
{Position: smokePos{Row: 10, Col: 10}, Owner: 1},
{Position: smokePos{Row: 20, Col: 15}, Owner: 2},
},
Energy: []smokePos{
{Row: 12, Col: 12},
{Row: 5, Col: 30},
},
Cores: []smokeCore{
{Position: smokePos{Row: 15, Col: 15}, Owner: 1, Active: true},
},
Walls: []smokePos{},
Dead: []smokeBot{},
}
}
// moveResponse is the expected JSON structure of a /turn response.
type moveResponse struct {
Moves []json.RawMessage `json:"moves"`
}
// validateMoveResponse checks the bot returned valid JSON with a "moves" array.
// An empty moves array is accepted (the bot may legally choose to idle).
func validateMoveResponse(body []byte) error {
var resp moveResponse
if err := json.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("invalid JSON in /turn response: %w (body: %.200s)", err, body)
}
return nil
}

View file

@ -0,0 +1,78 @@
package validator
import (
"fmt"
"regexp"
)
// endpointSpec holds the regex patterns used to detect the two required
// HTTP endpoints in a bot's source code.
type endpointSpec struct {
// healthRe matches the /health endpoint route registration.
healthRe *regexp.Regexp
// turnRe matches the /turn endpoint route registration.
turnRe *regexp.Regexp
}
// specs maps canonical language names to their detection patterns.
// The patterns are intentionally broad to accommodate the various HTTP
// frameworks an LLM might choose (net/http, Flask, Express, Actix, etc.).
var specs = map[string]endpointSpec{
"go": {
// Matches string literals "/health" and "/turn" (double-quote or backtick).
healthRe: regexp.MustCompile("[\"`]/health[\"`]"),
turnRe: regexp.MustCompile("[\"`]/turn[\"`]"),
},
"python": {
healthRe: regexp.MustCompile(`['"]/health['"]`),
turnRe: regexp.MustCompile(`['"]/turn['"]`),
},
"rust": {
healthRe: regexp.MustCompile(`['"]/health['"]`),
turnRe: regexp.MustCompile(`['"]/turn['"]`),
},
"typescript": {
healthRe: regexp.MustCompile(`['"]/health['"]`),
turnRe: regexp.MustCompile(`['"]/turn['"]`),
},
"java": {
healthRe: regexp.MustCompile(`['"]/health['"]`),
turnRe: regexp.MustCompile(`['"]/turn['"]`),
},
"php": {
healthRe: regexp.MustCompile(`['"]/health['"]`),
turnRe: regexp.MustCompile(`['"]/turn['"]`),
},
}
// movesRe detects whether the source references a JSON "moves" field, which
// is required in the POST /turn response.
var movesRe = regexp.MustCompile(`(?i)["']moves["']|\bmoves\b`)
// CheckSchema performs static analysis on code to confirm it exposes the two
// required HTTP endpoints and returns a moves response:
//
// - GET /health → must return HTTP 200
// - POST /turn → must accept JSON game state and return {"moves":[...]}
//
// The check is language-aware but uses lightweight regex matching — it does
// not perform full AST analysis. False negatives (a syntactically unusual
// but correct bot) are possible; they will be caught by the sandbox stage.
func CheckSchema(code, language string) error {
spec, ok := specs[language]
if !ok {
return fmt.Errorf("unsupported language: %s", language)
}
if !spec.healthRe.MatchString(code) {
return fmt.Errorf("schema: /health endpoint not found — bot must implement GET /health")
}
if !spec.turnRe.MatchString(code) {
return fmt.Errorf("schema: /turn endpoint not found — bot must implement POST /turn")
}
if !movesRe.MatchString(code) {
return fmt.Errorf(`schema: no "moves" field detected — bot must return JSON {"moves":[...]}`)
}
return nil
}

View file

@ -0,0 +1,175 @@
package validator
import (
"context"
"fmt"
"go/parser"
"go/token"
"os"
"os/exec"
"path/filepath"
"regexp"
"time"
)
// CheckSyntax validates the syntax of code for the given language.
// Returns nil when the code is syntactically valid.
//
// For Go it uses the stdlib go/parser (no subprocess). For other
// languages it shells out to the language's own syntax-checker binary;
// if the binary is not installed it falls back to a brace-balance check.
func CheckSyntax(ctx context.Context, code, language string, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
switch language {
case "go":
return checkGoSyntax(code)
case "python":
return checkWithTempFile(ctx, code, "bot.py",
func(path string) *exec.Cmd {
return exec.CommandContext(ctx, "python3", "-m", "py_compile", path)
})
case "rust":
return checkRustSyntax(ctx, code)
case "typescript":
return checkTSSyntax(ctx, code)
case "java":
return checkJavaSyntax(ctx, code)
case "php":
return checkWithTempFile(ctx, code, "bot.php",
func(path string) *exec.Cmd {
return exec.CommandContext(ctx, "php", "-l", path)
})
default:
return fmt.Errorf("unsupported language: %s", language)
}
}
// checkGoSyntax uses the stdlib go/parser to validate Go source.
// This is fast, dependency-free, and catches all parse errors.
func checkGoSyntax(code string) error {
fset := token.NewFileSet()
if _, err := parser.ParseFile(fset, "bot.go", code, 0); err != nil {
return fmt.Errorf("go syntax: %w", err)
}
return nil
}
// checkRustSyntax tries rustfmt --check first, then falls back to brace balance.
func checkRustSyntax(ctx context.Context, code string) error {
if _, err := exec.LookPath("rustfmt"); err == nil {
return checkWithTempFile(ctx, code, "bot.rs",
func(path string) *exec.Cmd {
return exec.CommandContext(ctx, "rustfmt", "--check", path)
})
}
return checkBraceBalance(code, "rust")
}
// checkTSSyntax runs tsc --noEmit when available, then falls back to brace balance.
func checkTSSyntax(ctx context.Context, code string) error {
if _, err := exec.LookPath("tsc"); err != nil {
return checkBraceBalance(code, "typescript")
}
dir, err := os.MkdirTemp("", "acb-syntax-ts-*")
if err != nil {
return fmt.Errorf("mkdirtemp: %w", err)
}
defer os.RemoveAll(dir)
if err := os.WriteFile(filepath.Join(dir, "bot.ts"), []byte(code), 0o600); err != nil {
return fmt.Errorf("write temp file: %w", err)
}
// Minimal tsconfig so tsc accepts a single file without a project.
tsconfig := `{"compilerOptions":{"target":"ES2020","module":"commonjs","strict":false,"noEmit":true},"files":["bot.ts"]}`
if err := os.WriteFile(filepath.Join(dir, "tsconfig.json"), []byte(tsconfig), 0o600); err != nil {
return fmt.Errorf("write tsconfig: %w", err)
}
cmd := exec.CommandContext(ctx, "tsc", "--project", filepath.Join(dir, "tsconfig.json"))
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("typescript syntax: %s", truncate(string(out), 512))
}
return nil
}
// checkJavaSyntax compiles with javac (syntax pass only; output discarded).
func checkJavaSyntax(ctx context.Context, code string) error {
className := extractJavaPublicClass(code)
if className == "" {
className = "Bot"
}
return checkWithTempFile(ctx, code, className+".java",
func(path string) *exec.Cmd {
// -Xlint:none suppresses lint warnings so only errors appear.
return exec.CommandContext(ctx, "javac", "-Xlint:none", path)
})
}
// checkWithTempFile writes code to a temp directory as filename, then runs
// the command returned by cmdFn(filePath) and returns its error, if any.
func checkWithTempFile(ctx context.Context, code, filename string, cmdFn func(string) *exec.Cmd) error {
dir, err := os.MkdirTemp("", "acb-syntax-*")
if err != nil {
return fmt.Errorf("mkdirtemp: %w", err)
}
defer os.RemoveAll(dir)
path := filepath.Join(dir, filename)
if err := os.WriteFile(path, []byte(code), 0o600); err != nil {
return fmt.Errorf("write temp file: %w", err)
}
cmd := cmdFn(path)
out, err := cmd.CombinedOutput()
if err != nil {
msg := string(out)
if msg == "" {
return fmt.Errorf("syntax check failed: %w", err)
}
return fmt.Errorf("%s", truncate(msg, 512))
}
return nil
}
// extractJavaPublicClass returns the name of the first public class in src.
var javaPublicClassRe = regexp.MustCompile(`(?m)^\s*public\s+class\s+(\w+)`)
func extractJavaPublicClass(src string) string {
m := javaPublicClassRe.FindStringSubmatch(src)
if len(m) < 2 {
return ""
}
return m[1]
}
// checkBraceBalance is a last-resort fallback that verifies { } are balanced.
func checkBraceBalance(code, lang string) error {
depth := 0
for _, ch := range code {
switch ch {
case '{':
depth++
case '}':
depth--
if depth < 0 {
return fmt.Errorf("%s syntax: unexpected '}'", lang)
}
}
}
if depth != 0 {
return fmt.Errorf("%s syntax: unmatched '{' (depth %d at EOF)", lang, depth)
}
return nil
}
// truncate limits s to at most n runes, appending "…" when truncated.
func truncate(s string, n int) string {
runes := []rune(s)
if len(runes) <= n {
return s
}
return string(runes[:n]) + "…"
}

View file

@ -0,0 +1,139 @@
// Package validator implements a three-stage validation pipeline for
// LLM-generated bot candidates:
//
// 1. Syntax — parse the generated code for the target language
// 2. Schema — verify the bot exposes the required HTTP endpoints
// 3. Sandbox — run the bot in a nsjail container, send 5 test /turn
// requests, and verify valid JSON responses
//
// The pipeline is fail-fast: if any stage fails, subsequent stages are
// skipped. The raw LLM output is preserved in Report so the evolution
// loop can embed it in retry prompts.
package validator
import (
"context"
"time"
)
// Stage identifies a validation pipeline stage.
type Stage string
const (
StageSyntax Stage = "syntax"
StageSchema Stage = "schema"
StageSandbox Stage = "sandbox"
)
// StageResult holds the outcome of one pipeline stage.
type StageResult struct {
Stage Stage
Passed bool
Error string // non-empty on failure
Duration time.Duration // wall-clock time for the stage
}
// Report is the complete outcome of a validation run. It is returned
// even when a stage fails so callers can log the partial results.
type Report struct {
Language string
Stages []StageResult
Passed bool // true only when all three stages pass
LLMOutput string // raw LLM response; preserved for retry / learning
}
// LastStage returns the name of the last stage that was executed (whether
// it passed or not). Returns "" when no stages ran.
func (r *Report) LastStage() Stage {
if len(r.Stages) == 0 {
return ""
}
return r.Stages[len(r.Stages)-1].Stage
}
// Config controls pipeline behaviour.
type Config struct {
// SyntaxTimeout caps the external process used for syntax checking.
SyntaxTimeout time.Duration
// SandboxTimeout caps the entire sandbox smoke test (build + run + requests).
SandboxTimeout time.Duration
// SmokeRequests is the number of /turn requests sent during the smoke test.
SmokeRequests int
// UseNsjail enables nsjail-based process isolation during the smoke test.
// Falls back to plain exec when nsjail is not found in PATH.
UseNsjail bool
// NsjailPath overrides the nsjail binary name / path (default "nsjail").
NsjailPath string
}
// DefaultConfig returns a Config with production-ready defaults.
func DefaultConfig() Config {
return Config{
SyntaxTimeout: 15 * time.Second,
SandboxTimeout: 60 * time.Second,
SmokeRequests: 5,
UseNsjail: true,
NsjailPath: "nsjail",
}
}
// Validate runs the full three-stage pipeline for the given bot code.
// llmOutput is the raw text from which code was extracted; it is stored
// in the report for retry or learning.
//
// The returned error is only non-nil for unexpected infrastructure failures
// (e.g. temp-dir creation). Validation failures are encoded in Report.Passed
// and the individual StageResult.Error fields.
func Validate(ctx context.Context, code, language, llmOutput string, cfg Config) (*Report, error) {
r := &Report{
Language: language,
LLMOutput: llmOutput,
}
type step struct {
name Stage
fn func(context.Context) error
}
steps := []step{
{
StageSyntax,
func(ctx context.Context) error {
return CheckSyntax(ctx, code, language, cfg.SyntaxTimeout)
},
},
{
StageSchema,
func(_ context.Context) error {
return CheckSchema(code, language)
},
},
{
StageSandbox,
func(ctx context.Context) error {
return RunSmokeTest(ctx, code, language, cfg)
},
},
}
allPassed := true
for _, s := range steps {
t0 := time.Now()
err := s.fn(ctx)
sr := StageResult{
Stage: s.name,
Passed: err == nil,
Duration: time.Since(t0),
}
if err != nil {
sr.Error = err.Error()
allPassed = false
}
r.Stages = append(r.Stages, sr)
if err != nil {
break // fail-fast: skip remaining stages
}
}
r.Passed = allPassed
return r, nil
}

View file

@ -0,0 +1,347 @@
package validator
import (
"context"
"os/exec"
"testing"
"time"
)
// ── Syntax tests ──────────────────────────────────────────────────────────
func TestCheckSyntax_Go_Valid(t *testing.T) {
code := `package main
import "net/http"
func main() { http.ListenAndServe(":8080", nil) }
`
if err := CheckSyntax(context.Background(), code, "go", 5*time.Second); err != nil {
t.Fatalf("expected valid Go to pass, got: %v", err)
}
}
func TestCheckSyntax_Go_Invalid(t *testing.T) {
code := `package main
func main() {
x := // missing value
}
`
if err := CheckSyntax(context.Background(), code, "go", 5*time.Second); err == nil {
t.Fatal("expected invalid Go to fail, but got nil")
}
}
func TestCheckSyntax_Go_UnmatchedBrace(t *testing.T) {
code := `package main
func main() {
if true {
`
if err := CheckSyntax(context.Background(), code, "go", 5*time.Second); err == nil {
t.Fatal("expected unmatched brace to fail, but got nil")
}
}
func TestCheckSyntax_UnsupportedLanguage(t *testing.T) {
if err := CheckSyntax(context.Background(), "code", "cobol", 5*time.Second); err == nil {
t.Fatal("expected unsupported language to fail")
}
}
func TestCheckSyntax_Python_Valid(t *testing.T) {
if _, err := exec.LookPath("python3"); err != nil {
t.Skip("python3 not in PATH")
}
code := `
import json, os
from http.server import HTTPServer, BaseHTTPRequestHandler
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == '/health':
self.send_response(200)
self.end_headers()
self.wfile.write(b'OK')
if __name__ == '__main__':
HTTPServer(('', int(os.getenv('BOT_PORT', 8080))), Handler).serve_forever()
`
if err := CheckSyntax(context.Background(), code, "python", 10*time.Second); err != nil {
t.Fatalf("expected valid Python to pass, got: %v", err)
}
}
func TestCheckSyntax_Python_Invalid(t *testing.T) {
if _, err := exec.LookPath("python3"); err != nil {
t.Skip("python3 not in PATH")
}
code := `def foo(
x = 1
y = 2 # missing comma / closing paren
`
if err := CheckSyntax(context.Background(), code, "python", 10*time.Second); err == nil {
t.Fatal("expected invalid Python to fail")
}
}
// ── Schema tests ──────────────────────────────────────────────────────────
func TestCheckSchema_Go_Complete(t *testing.T) {
code := `package main
import "net/http"
func main() {
http.HandleFunc("/health", handleHealth)
http.HandleFunc("/turn", handleTurn)
http.ListenAndServe(":8080", nil)
}
func handleHealth(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }
func handleTurn(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(` + "`" + `{"moves":[]}` + "`" + `))
}
`
if err := CheckSchema(code, "go"); err != nil {
t.Fatalf("expected complete bot to pass schema: %v", err)
}
}
func TestCheckSchema_Go_MissingHealth(t *testing.T) {
code := `package main
import "net/http"
func main() {
http.HandleFunc("/turn", handleTurn)
http.ListenAndServe(":8080", nil)
}
func handleTurn(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(` + "`" + `{"moves":[]}` + "`" + `))
}
`
if err := CheckSchema(code, "go"); err == nil {
t.Fatal("expected missing /health to fail schema check")
}
}
func TestCheckSchema_Go_MissingTurn(t *testing.T) {
code := `package main
import "net/http"
func main() {
http.HandleFunc("/health", handleHealth)
http.ListenAndServe(":8080", nil)
}
func handleHealth(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }
`
if err := CheckSchema(code, "go"); err == nil {
t.Fatal("expected missing /turn to fail schema check")
}
}
func TestCheckSchema_Go_MissingMoves(t *testing.T) {
code := `package main
import "net/http"
func main() {
http.HandleFunc("/health", handleHealth)
http.HandleFunc("/turn", handleTurn)
http.ListenAndServe(":8080", nil)
}
func handleHealth(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }
func handleTurn(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(` + "`" + `{"result":"ok"}` + "`" + `))
}
`
if err := CheckSchema(code, "go"); err == nil {
t.Fatal("expected missing moves field to fail schema check")
}
}
func TestCheckSchema_UnsupportedLanguage(t *testing.T) {
if err := CheckSchema("code", "brainfuck"); err == nil {
t.Fatal("expected unsupported language to fail")
}
}
// ── Pipeline tests ────────────────────────────────────────────────────────
func TestValidate_FailFastOnSyntax(t *testing.T) {
code := `package main
func main() { // missing closing brace`
cfg := DefaultConfig()
cfg.SandboxTimeout = 5 * time.Second
report, err := Validate(context.Background(), code, "go", "raw llm output", cfg)
if err != nil {
t.Fatalf("Validate returned unexpected error: %v", err)
}
if report.Passed {
t.Fatal("expected pipeline to fail")
}
if report.LastStage() != StageSyntax {
t.Fatalf("expected to stop at syntax stage, got %q", report.LastStage())
}
if len(report.Stages) != 1 {
t.Fatalf("expected 1 stage result (fail-fast), got %d", len(report.Stages))
}
if report.LLMOutput != "raw llm output" {
t.Fatalf("LLMOutput not preserved: %q", report.LLMOutput)
}
}
func TestValidate_FailFastOnSchema(t *testing.T) {
// Syntactically valid Go, but missing /turn endpoint.
code := `package main
import "net/http"
func main() {
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
http.ListenAndServe(":8080", nil)
}
`
cfg := DefaultConfig()
cfg.SandboxTimeout = 5 * time.Second
report, err := Validate(context.Background(), code, "go", "", cfg)
if err != nil {
t.Fatalf("Validate returned unexpected error: %v", err)
}
if report.Passed {
t.Fatal("expected pipeline to fail")
}
if report.LastStage() != StageSchema {
t.Fatalf("expected to stop at schema stage, got %q", report.LastStage())
}
if len(report.Stages) != 2 {
t.Fatalf("expected 2 stage results, got %d", len(report.Stages))
}
}
// TestValidate_FullPipeline_Go runs the complete pipeline including the
// sandbox smoke test using a minimal but complete Go bot.
// It is skipped when the `go` binary is not in PATH.
func TestValidate_FullPipeline_Go(t *testing.T) {
if _, err := exec.LookPath("go"); err != nil {
t.Skip("go not in PATH")
}
code := minimalGoBot()
cfg := DefaultConfig()
cfg.UseNsjail = false // nsjail may not be available in CI
cfg.SmokeRequests = 5
cfg.SandboxTimeout = 45 * time.Second
cfg.SyntaxTimeout = 10 * time.Second
report, err := Validate(context.Background(), code, "go", "test llm output", cfg)
if err != nil {
t.Fatalf("Validate returned unexpected error: %v", err)
}
if !report.Passed {
for _, sr := range report.Stages {
if !sr.Passed {
t.Errorf("stage %s failed: %s", sr.Stage, sr.Error)
}
}
t.Fatalf("expected pipeline to pass")
}
if len(report.Stages) != 3 {
t.Fatalf("expected 3 stage results, got %d", len(report.Stages))
}
}
// minimalGoBot returns a minimal, complete Go bot that passes all three
// validation stages. It uses the reference signature format from bots/gatherer.
func minimalGoBot() string {
return `package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
)
type MoveResponse struct {
Moves []interface{} ` + "`json:\"moves\"`" + `
}
func verifySignature(secret, matchID, turnStr string, body []byte, signature string) error {
h := sha256.Sum256(body)
import_str := fmt.Sprintf("%s.%s.%s", matchID, turnStr, hex.EncodeToString(h[:]))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(import_str))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(signature), []byte(expected)) {
return fmt.Errorf("invalid signature")
}
return nil
}
func main() {
secret := os.Getenv("BOT_SECRET")
if secret == "" {
log.Fatal("BOT_SECRET required")
}
port := os.Getenv("BOT_PORT")
if port == "" {
port = "8080"
}
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
})
http.HandleFunc("/turn", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
defer r.Body.Close()
sig := r.Header.Get("X-ACB-Signature")
matchID := r.Header.Get("X-ACB-Match-Id")
turn := r.Header.Get("X-ACB-Turn")
if err := verifySignature(secret, matchID, turn, body, sig); err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
resp := MoveResponse{Moves: []interface{}{}}
out, _ := json.Marshal(resp)
w.Header().Set("Content-Type", "application/json")
w.Write(out)
})
log.Printf("bot starting on :%s", port)
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatal(err)
}
}
`
}

222
cmd/acb-evolver/main.go Normal file
View file

@ -0,0 +1,222 @@
// acb-evolver manages the evolution pipeline programs database.
//
// Subcommands:
//
// init-schema Create or migrate the programs table in PostgreSQL
// seed Insert the 6 built-in strategy bots as generation-0 programs
// stats Print program counts per island
// validate Run the 3-stage validation pipeline on a bot source file
// validation-stats Show per-island validation pass-rate metrics
package main
import (
"context"
"database/sql"
"flag"
"fmt"
"log"
"os"
"strings"
_ "github.com/lib/pq"
evolverdb "github.com/aicodebattle/acb/cmd/acb-evolver/internal/db"
"github.com/aicodebattle/acb/cmd/acb-evolver/internal/validator"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: acb-evolver <init-schema|seed|stats|validate|validation-stats>")
os.Exit(1)
}
dbURL := os.Getenv("ACB_DATABASE_URL")
if dbURL == "" {
dbURL = "postgres://localhost:5432/acb?sslmode=disable"
}
ctx := context.Background()
switch os.Args[1] {
case "init-schema":
db := mustOpenDB(dbURL)
defer db.Close()
if err := evolverdb.EnsureSchema(ctx, db); err != nil {
log.Fatalf("init-schema: %v", err)
}
log.Println("schema ready")
case "seed":
db := mustOpenDB(dbURL)
defer db.Close()
store := evolverdb.NewStore(db)
if err := evolverdb.EnsureSchema(ctx, db); err != nil {
log.Fatalf("ensure schema: %v", err)
}
n, err := evolverdb.SeedPopulation(ctx, store)
if err != nil {
log.Fatalf("seed: %v", err)
}
if n == 0 {
log.Println("programs table already seeded; nothing to do")
} else {
log.Printf("seeded %d programs", n)
}
case "stats":
db := mustOpenDB(dbURL)
defer db.Close()
store := evolverdb.NewStore(db)
counts, err := store.CountByIsland(ctx)
if err != nil {
log.Fatalf("stats: %v", err)
}
total := 0
for _, island := range evolverdb.AllIslands {
n := counts[island]
total += n
fmt.Printf(" %-8s %d\n", island, n)
}
fmt.Printf(" %-8s %d\n", "total", total)
case "validate":
runValidate(ctx, dbURL, os.Args[2:])
case "validation-stats":
db := mustOpenDB(dbURL)
defer db.Close()
store := evolverdb.NewStore(db)
runValidationStats(ctx, store)
default:
fmt.Fprintf(os.Stderr, "unknown subcommand %q\n", os.Args[1])
fmt.Fprintln(os.Stderr, "usage: acb-evolver <init-schema|seed|stats|validate|validation-stats>")
os.Exit(1)
}
}
// runValidate parses flags, runs the three-stage validation pipeline on a bot
// source file, and optionally logs the result to the database.
//
// validate -lang go [-island alpha] [-nsjail] <file>
func runValidate(ctx context.Context, dbURL string, args []string) {
fs := flag.NewFlagSet("validate", flag.ExitOnError)
lang := fs.String("lang", "", "bot language (go|python|rust|typescript|java|php) [required]")
island := fs.String("island", "alpha", "island name for DB logging (alpha|beta|gamma|delta)")
useNsjail := fs.Bool("nsjail", false, "wrap sandbox in nsjail (requires nsjail in PATH)")
nolog := fs.Bool("nolog", false, "skip writing result to the database")
if err := fs.Parse(args); err != nil {
os.Exit(1)
}
if *lang == "" {
fmt.Fprintln(os.Stderr, "validate: -lang is required")
fs.Usage()
os.Exit(1)
}
if fs.NArg() < 1 {
fmt.Fprintln(os.Stderr, "validate: file argument is required")
fs.Usage()
os.Exit(1)
}
filePath := fs.Arg(0)
code, err := os.ReadFile(filePath)
if err != nil {
log.Fatalf("read file: %v", err)
}
cfg := validator.DefaultConfig()
cfg.UseNsjail = *useNsjail
report, err := validator.Validate(ctx, string(code), *lang, "", cfg)
if err != nil {
log.Fatalf("validate: %v", err)
}
printReport(report, filePath)
// Persist the result unless -nolog was set.
if !*nolog {
db, err := sql.Open("postgres", dbURL)
if err != nil {
log.Printf("warn: could not open DB for logging (%v) — skipping", err)
} else {
defer db.Close()
store := evolverdb.NewStore(db)
entry := &evolverdb.ValidationLog{
Island: *island,
Language: *lang,
Stage: string(report.LastStage()),
Passed: report.Passed,
LLMOutput: report.LLMOutput,
}
if !report.Passed {
for _, sr := range report.Stages {
if !sr.Passed {
entry.ErrorText = sr.Error
break
}
}
}
if logErr := store.RecordValidation(ctx, entry); logErr != nil {
log.Printf("warn: DB log failed: %v", logErr)
}
}
}
if !report.Passed {
os.Exit(1)
}
}
// printReport prints a human-readable summary of the validation report.
func printReport(r *validator.Report, src string) {
fmt.Printf("Validation report for %s (%s)\n", src, r.Language)
fmt.Println(strings.Repeat("─", 50))
for _, sr := range r.Stages {
status := "PASS"
if !sr.Passed {
status = "FAIL"
}
fmt.Printf(" %-8s %s (%s)\n", sr.Stage, status, sr.Duration.Round(1000000))
if !sr.Passed && sr.Error != "" {
fmt.Printf(" %s\n", sr.Error)
}
}
fmt.Println(strings.Repeat("─", 50))
if r.Passed {
fmt.Println(" Result: PASSED — all 3 stages OK")
} else {
fmt.Printf(" Result: FAILED at stage %q\n", r.LastStage())
}
}
// runValidationStats queries and prints per-island validation statistics.
func runValidationStats(ctx context.Context, store *evolverdb.Store) {
stats, err := store.IslandValidationStats(ctx)
if err != nil {
log.Fatalf("validation-stats: %v", err)
}
if len(stats) == 0 {
fmt.Println("no validation records found")
return
}
fmt.Printf("%-8s %6s %6s %7s %7s %7s %7s\n",
"island", "total", "passed", "rate", "!syntax", "!schema", "!sandbox")
fmt.Println(strings.Repeat("─", 66))
for _, v := range stats {
fmt.Printf("%-8s %6d %6d %6.1f%% %7d %7d %7d\n",
v.Island, v.Total, v.Passed, v.PassRate*100,
v.ByStage["syntax"], v.ByStage["schema"], v.ByStage["sandbox"])
}
}
func mustOpenDB(url string) *sql.DB {
db, err := sql.Open("postgres", url)
if err != nil {
log.Fatalf("open database: %v", err)
}
return db
}