ai-code-battle/cmd/acb-evolver/run.go
jedarden 5242d6037c feat(acb-evolver): add weekly automated map evolution ticker
Wire up the acb-map-evolver to run automatically on a weekly schedule
(Sunday 03:00 UTC by default) from the evolver deployment.

The map evolution ticker:
- Waits until the next scheduled time (weekday:hour:minute UTC)
- Runs acb-map-evolver --once to evolve maps for all player counts
- Repeats every 7 days

The schedule can be configured via ACB_MAP_EVOLUTION_SCHEDULE env var
(format: WEEKDAY:HH:MM, e.g., "0:03:00" for Sunday 03:00 UTC).

Enable via ACB_MAP_EVOLUTION_ENABLED=true or --enable-map-evolution flag.

Per plan §14.6: the weekly map evolution loads engagement scores,
runs MAP-Elites evolution, promotes high-scoring variants, and updates
the active map pool in the database.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 09:26:38 -04:00

955 lines
28 KiB
Go

// Package main provides the autonomous evolution loop command.
//
// The 'run' subcommand executes the full evolution pipeline autonomously:
// 1. Select island (round-robin)
// 2. Select parents via tournament selection
// 3. Build prompt with meta context
// 4. Generate candidate via LLM ensemble
// 5. Insert candidate into programs database
// 6. Run 3-stage validation (syntax → schema → sandbox)
// 7. If validation fails, retry with error feedback (up to N times)
// 8. Run arena tournament (10 matches vs live opponents)
// 9. Apply promotion gate (Nash + MAP-Elites)
// 10. If promoted, deploy to K8s and register in bots table
// 11. Enforce retirement policy
// 12. Export live.json for dashboard
// 13. Repeat
package main
import (
"context"
"database/sql"
"flag"
"fmt"
"log"
"math/rand"
"os"
"os/exec"
"os/signal"
"strings"
"syscall"
"time"
_ "github.com/lib/pq"
evolverdb "github.com/aicodebattle/acb/cmd/acb-evolver/internal/db"
"github.com/aicodebattle/acb/cmd/acb-evolver/internal/arena"
"github.com/aicodebattle/acb/cmd/acb-evolver/internal/crosspoll"
"github.com/aicodebattle/acb/cmd/acb-evolver/internal/live"
"github.com/aicodebattle/acb/cmd/acb-evolver/internal/llm"
"github.com/aicodebattle/acb/cmd/acb-evolver/internal/mapelites"
"github.com/aicodebattle/acb/cmd/acb-evolver/internal/meta"
"github.com/aicodebattle/acb/cmd/acb-evolver/internal/promoter"
"github.com/aicodebattle/acb/cmd/acb-evolver/internal/prompt"
"github.com/aicodebattle/acb/cmd/acb-evolver/internal/selector"
"github.com/aicodebattle/acb/cmd/acb-evolver/internal/validator"
"github.com/aicodebattle/acb/metrics"
)
// RunConfig holds configuration for the autonomous evolution loop.
type RunConfig struct {
// Evolution parameters
NumParents int // number of parents for tournament selection
TournamentK int // tournament size
MaxRetries int // max LLM retries on validation failure
TopBotLimit int // number of top bots for meta description
// Gate thresholds
NashThreshold float64 // Nash value threshold for promotion
WinRateLowerBound float64 // Wilson CI lower bound threshold
// Retirement
RatingThreshold float64 // minimum display rating to keep
PopCap int // max evolved bots in fleet
// Timing
CycleInterval time.Duration // delay between cycles (0 = continuous)
IslandCooldown time.Duration // min time between same-island evolutions
RetirementCheckInterval time.Duration // interval between periodic retirement checks
// Infrastructure
LLMURL string
RepoDir string
Registry string
KubectlServer string
EncryptionKey string
UseNsjail bool
LiveExportPath string
UploadR2 bool
// Declarative config for K8s manifests (§10.8)
DeclarativeConfigRepo string // git repo URL for K8s manifests
DeclarativeConfigBranch string // git branch for K8s manifests
// Languages to evolve (in priority order)
Languages []string
// PagesBaseURL is the Cloudflare Pages base URL for reading static indexes
// such as community_hints.json. Empty disables community hint loading.
PagesBaseURL string
// Map evolution ticker (§14.6)
MapEvolutionEnabled bool // whether to trigger weekly map evolution
MapEvolutionSchedule WeeklySchedule // when to run map evolution
}
// WeeklySchedule configures when the weekly evolution run fires.
type WeeklySchedule struct {
Weekday time.Weekday // 0=Sunday, 1=Monday, ..., 6=Saturday
Hour int // 0-23 (UTC)
Minute int // 0-59
// PagesBaseURL is the Cloudflare Pages base URL for reading static indexes
// such as community_hints.json. Empty disables community hint loading.
PagesBaseURL string
}
// DefaultRunConfig returns production-ready defaults.
func DefaultRunConfig() RunConfig {
return RunConfig{
NumParents: 2,
TournamentK: 3,
MaxRetries: 2,
TopBotLimit: 10,
NashThreshold: 0.50,
WinRateLowerBound: 0.40,
RatingThreshold: 800.0,
PopCap: 50,
CycleInterval: 5 * time.Minute,
RetirementCheckInterval: 24 * time.Hour,
IslandCooldown: 2 * time.Minute,
LLMURL: envOrDefault("ACB_LLM_URL", "http://zai-proxy-apexalgo.tail1b1987.ts.net:8080"),
RepoDir: envOrDefault("ACB_REPO_DIR", "."),
Registry: envOrDefault("ACB_REGISTRY", "forgejo.ardenone.com/ai-code-battle"),
KubectlServer: envOrDefault("ACB_KUBECTL_SERVER", "http://kubectl-ardenone-cluster:8001"),
EncryptionKey: os.Getenv("ACB_ENCRYPTION_KEY"),
UseNsjail: true,
LiveExportPath: envOrDefault("ACB_EVOLUTION_OUT", "evolution/live.json"),
UploadR2: false,
DeclarativeConfigRepo: envOrDefault("ACB_DECLARATIVE_CONFIG_REPO", "https://forgejo.ardenone.com/infra/ardenone-cluster.git"),
DeclarativeConfigBranch: envOrDefault("ACB_DECLARATIVE_CONFIG_BRANCH", "main"),
Languages: []string{"go", "python", "rust", "typescript", "java", "php"},
MapEvolutionEnabled: envOrDefault("ACB_MAP_EVOLUTION_ENABLED", "false") == "true",
MapEvolutionSchedule: WeeklySchedule{
Weekday: time.Sunday, // Default: Sunday 03:00 UTC
Hour: 3,
Minute: 0,
},
}
}
// RunStats tracks evolution loop statistics.
type RunStats struct {
Cycles int
Generated int
Validated int
ValidationFailed int
Evaluated int
Promoted int
Retired int
CrossPollinated int
Errors int
StartTime time.Time
}
// RunEvolutionLoop executes the autonomous evolution pipeline.
//
// Usage: acb-evolver run [-continuous] [-island alpha] [-lang go] [-v]
func RunEvolutionLoop(ctx context.Context, dbURL string, args []string) {
fs := flag.NewFlagSet("run", flag.ExitOnError)
continuous := fs.Bool("continuous", false, "run continuously until interrupted")
singleIsland := fs.String("island", "", "evolve only this island (empty = round-robin)")
singleLang := fs.String("lang", "", "use only this language (empty = rotate)")
seed := fs.Int64("seed", 0, "random seed (0 = time)")
verbose := fs.Bool("v", false, "verbose output")
dryRun := fs.Bool("dry-run", false, "simulate without deploying")
maxCycles := fs.Int("max-cycles", 0, "stop after N cycles (0 = unlimited)")
enableMapEvolution := fs.Bool("enable-map-evolution", false, "enable weekly map evolution ticker")
if err := fs.Parse(args); err != nil {
os.Exit(1)
}
// Initialize RNG
rng := rand.New(rand.NewSource(*seed))
if *seed == 0 {
rng = rand.New(rand.NewSource(time.Now().UnixNano()))
}
// Start Prometheus metrics server
metricsSrv := metrics.StartServer()
defer metricsSrv.Close()
// Open database
db, err := sql.Open("postgres", dbURL)
if err != nil {
log.Fatalf("open database: %v", err)
}
defer db.Close()
store := evolverdb.NewStore(db)
// Load config from env with overrides
cfg := DefaultRunConfig()
if *singleLang != "" {
cfg.Languages = []string{*singleLang}
}
if *enableMapEvolution {
cfg.MapEvolutionEnabled = true
}
// Parse weekly schedule from env (format: "WEEKDAY:HH:MM" e.g., "0:03:00" for Sunday 03:00)
if v := os.Getenv("ACB_MAP_EVOLUTION_SCHEDULE"); v != "" {
var weekday, hour, minute int
if _, err := fmt.Sscanf(v, "%d:%d:%d", &weekday, &hour, &minute); err == nil {
if weekday >= 0 && weekday <= 6 && hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 {
cfg.MapEvolutionSchedule.Weekday = time.Weekday(weekday)
cfg.MapEvolutionSchedule.Hour = hour
cfg.MapEvolutionSchedule.Minute = minute
}
}
}
// Track last evolution time per island for cooldown
lastEvolved := make(map[string]time.Time)
// Track per-island generation counters for cross-pollination boundary detection.
// Load persisted state from DB so we don't re-trigger on restart.
prevGens, err := store.LoadCrossPollState(ctx)
if err != nil {
log.Printf("warn: could not load cross-pollination state (starting fresh): %v", err)
prevGens = make(map[string]int)
}
if *verbose {
log.Printf("Cross-pollination state: %v", prevGens)
}
// Stats
stats := RunStats{StartTime: time.Now()}
// Setup signal handling for graceful shutdown
ctx, cancel := context.WithCancel(ctx)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
log.Println("Received shutdown signal, finishing current cycle...")
cancel()
}()
// Start periodic retirement ticker (§10.8)
if cfg.RetirementCheckInterval > 0 {
go startRetirementTicker(ctx, db, store, cfg, &stats, *verbose)
}
// Start weekly map evolution ticker (§14.6)
if cfg.MapEvolutionEnabled {
go startMapEvolutionTicker(ctx, db, cfg, *verbose)
}
langIdx := 0
islandIdx := 0
log.Printf("Evolution loop starting (continuous=%v, dry-run=%v)", *continuous, *dryRun)
if *verbose {
log.Printf("Config: nash=%.2f, win-lower=%.2f, max-retries=%d, languages=%v, retirement-check=%v",
cfg.NashThreshold, cfg.WinRateLowerBound, cfg.MaxRetries, cfg.Languages, cfg.RetirementCheckInterval)
}
for {
select {
case <-ctx.Done():
printStats(&stats)
return
default:
}
// Select island (round-robin with cooldown)
var island string
if *singleIsland != "" {
island = *singleIsland
} else {
island = selectNextIsland(lastEvolved, cfg.IslandCooldown, islandIdx)
islandIdx = (islandIdx + 1) % len(evolverdb.AllIslands)
}
// Select language (rotate)
lang := cfg.Languages[langIdx%len(cfg.Languages)]
langIdx++
if *verbose {
log.Printf("=== Cycle %d: island=%s lang=%s ===", stats.Cycles+1, island, lang)
}
// Run one evolution cycle
promoted, err := runCycle(ctx, db, store, island, lang, cfg, rng, *verbose, *dryRun)
if err != nil {
log.Printf("Cycle failed: %v", err)
stats.Errors++
}
if promoted {
stats.Promoted++
}
stats.Cycles++
stats.Generated++
metrics.EvolverGenerations.Inc()
// Check cycle limit
if *maxCycles > 0 && stats.Cycles >= *maxCycles {
log.Printf("Reached max cycles (%d), stopping", *maxCycles)
printStats(&stats)
return
}
// Export live.json after each cycle
exportLive(ctx, db, cfg, *verbose)
// Check for cross-pollination (§10.2: every 50 generations per island)
cpChecker := crosspoll.NewChecker(store, llm.NewClient(cfg.LLMURL, ""), rng)
cpResults, err := cpChecker.CheckAndPollinate(ctx, prevGens, *verbose)
if err != nil {
log.Printf("Cross-pollination check error: %v", err)
}
stats.CrossPollinated += len(cpResults)
// Persist updated cross-pollination state so we don't re-trigger on restart.
for isl, gen := range prevGens {
if err := store.SaveCrossPollState(ctx, isl, gen); err != nil {
log.Printf("warn: could not save crosspoll state for %s: %v", isl, err)
}
}
// Continuous mode: wait for next cycle
if *continuous {
lastEvolved[island] = time.Now()
if cfg.CycleInterval > 0 {
if *verbose {
log.Printf("Sleeping %v until next cycle...", cfg.CycleInterval)
}
select {
case <-ctx.Done():
printStats(&stats)
return
case <-time.After(cfg.CycleInterval):
}
}
} else {
// Single-shot mode
printStats(&stats)
return
}
}
}
// selectNextIsland picks the next island to evolve, respecting cooldown.
func selectNextIsland(lastEvolved map[string]time.Time, cooldown time.Duration, startIdx int) string {
now := time.Now()
// Try each island starting from startIdx
for i := 0; i < len(evolverdb.AllIslands); i++ {
idx := (startIdx + i) % len(evolverdb.AllIslands)
island := evolverdb.AllIslands[idx]
last, ok := lastEvolved[island]
if !ok || now.Sub(last) >= cooldown {
return island
}
}
// All islands on cooldown - pick the one with longest time since last evolve
var oldestIsland string
var oldestTime time.Time
for _, island := range evolverdb.AllIslands {
last, ok := lastEvolved[island]
if !ok {
return island
}
if oldestTime.IsZero() || last.Before(oldestTime) {
oldestTime = last
oldestIsland = island
}
}
return oldestIsland
}
// runCycle executes one complete evolution cycle for the given island.
func runCycle(ctx context.Context, db *sql.DB, store *evolverdb.Store,
island, lang string, cfg RunConfig, rng *rand.Rand, verbose, dryRun bool) (bool, error) {
// 1. Load programs from the island
programs, err := store.ListByIsland(ctx, island)
if err != nil {
return false, fmt.Errorf("load programs: %w", err)
}
if len(programs) == 0 {
return false, fmt.Errorf("no programs on island %s - seed the database first", island)
}
// 2. Select parents via tournament selection
parents := selector.SelectParents(programs, cfg.NumParents, cfg.TournamentK, rng)
if verbose {
for i, p := range parents {
log.Printf(" Parent %d: id=%d fitness=%.3f", i+1, p.ID, p.Fitness)
}
}
// 3. Build meta description
metaBuilder := meta.NewBuilder(store)
metaDesc, err := metaBuilder.Build(ctx, cfg.TopBotLimit)
if err != nil {
log.Printf("warn: meta build failed: %v", err)
metaDesc = &meta.Description{TotalBots: len(programs), IslandStats: make(map[string]meta.IslandStats)}
}
// 4. Determine generation number
maxGen := 0
for _, p := range programs {
if p.Generation > maxGen {
maxGen = p.Generation
}
}
generation := maxGen + 1
// 5. Generate candidate with retry loop
var programID int64
var code string
var program *evolverdb.Program
var report *validator.Report
for retry := 0; retry <= cfg.MaxRetries; retry++ {
if retry > 0 && verbose {
log.Printf(" Retry %d/%d with error feedback...", retry, cfg.MaxRetries)
}
// Assemble prompt (with error feedback if retry)
req := prompt.BuildRequest(parents, nil, metaDesc, island, lang, generation)
if retry > 0 && report != nil {
// Add error feedback to prompt
req.TaskOverride = buildRetryPrompt(report, lang)
}
fullPrompt := prompt.Assemble(req)
// Run LLM ensemble
client := llm.NewClient(cfg.LLMURL, "")
ensembleCfg := llm.DefaultEnsembleConfig()
ensembleCfg.NumCandidates = 3
ensembleCfg.RefineTop = true
result, err := client.Ensemble(ctx, fullPrompt, lang, ensembleCfg)
if err != nil {
log.Printf("LLM ensemble failed: %v", err)
continue
}
if result.Best == nil {
log.Printf("No valid candidate from LLM")
continue
}
code = result.Best.Code
// Estimate behavior vector from code
behaviorVec := estimateBehaviorVector(code, lang)
// Insert into database first (so we have a program ID for tracking)
parentIDs := make([]int64, len(parents))
for i, p := range parents {
parentIDs[i] = p.ID
}
programID, err = store.Create(ctx, &evolverdb.Program{
Code: code,
Language: lang,
Island: island,
Generation: generation,
ParentIDs: parentIDs,
BehaviorVector: behaviorVec,
Fitness: 0.0,
Promoted: false,
})
if err != nil {
return false, fmt.Errorf("insert program: %w", err)
}
if verbose {
log.Printf(" Created program %d (gen %d)", programID, generation)
}
// Run validation
valCfg := validator.DefaultConfig()
valCfg.UseNsjail = cfg.UseNsjail
report, err = validator.Validate(ctx, code, lang, result.Best.Code, valCfg)
if err != nil {
log.Printf("Validation infrastructure error: %v", err)
store.Delete(ctx, programID)
programID = 0
continue
}
// Log validation result
valLog := &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 {
valLog.ErrorText = sr.Error
break
}
}
}
store.RecordValidation(ctx, valLog)
if !report.Passed {
if verbose {
log.Printf(" Validation FAILED at stage %s: %s", report.LastStage(), valLog.ErrorText)
}
store.Delete(ctx, programID)
programID = 0
continue // retry
}
// Validation passed - break out of retry loop
if verbose {
log.Printf(" Validation PASSED (all 3 stages)")
}
// Fetch the program for later use
program, _ = store.Get(ctx, programID)
break
}
// Check if we have a valid program
if programID == 0 || code == "" {
return false, fmt.Errorf("all retries exhausted without valid candidate")
}
// 6. Run arena evaluation
arenaCfg := arena.DefaultConfig()
arenaCfg.EncryptionKey = cfg.EncryptionKey
a := arena.New(db, arenaCfg)
if verbose {
log.Printf(" Running %d-match arena tournament...", arena.DefaultNumMatches)
}
arenaResult, err := a.Run(ctx, code, lang)
if err != nil {
store.Delete(ctx, programID)
return false, fmt.Errorf("arena: %w", err)
}
// Compute fitness (overall win rate)
wr := arena.ComputeFromResult(arenaResult)
fitness := wr.Rate
// Get behavior vector
var behaviorVec []float64
if program != nil && len(program.BehaviorVector) >= 2 {
behaviorVec = program.BehaviorVector
} else {
behaviorVec = []float64{0.5, 0.5}
}
// Update fitness in database
store.UpdateFitness(ctx, programID, fitness, behaviorVec)
if verbose {
log.Printf(" Arena result: %d W / %d L / %d D / %d err win rate=%.3f",
arenaResult.Wins, arenaResult.Losses, arenaResult.Draws, arenaResult.Errors, fitness)
}
// 7. Load MAP-Elites grid and apply promotion gate
grid := mapelites.New(10)
promotedPrograms, _ := store.ListPromoted(ctx)
for _, pp := range promotedPrograms {
if len(pp.BehaviorVector) >= 2 {
expl, form := 0.5, 0.5
if len(pp.BehaviorVector) >= 4 {
expl, form = pp.BehaviorVector[2], pp.BehaviorVector[3]
}
grid.TryPlace(pp.ProgramID, pp.Fitness, pp.BehaviorVector[0], pp.BehaviorVector[1], expl, form)
}
}
gateCfg := arena.GateConfig{
NashThreshold: cfg.NashThreshold,
WinRateLowerBound: cfg.WinRateLowerBound,
}
gate := arena.NewGate(gateCfg, grid)
gateResult := gate.Evaluate(arenaResult, programID, fitness, behaviorVec)
if verbose {
log.Printf(" Gate: %s", gateResult.Reason)
}
if !gateResult.Promoted {
if verbose {
log.Printf(" Decision: REJECTED")
}
return false, nil
}
if verbose {
log.Printf(" Decision: PROMOTED")
}
if dryRun {
log.Printf(" [dry-run] Would promote program %d", programID)
return true, nil
}
// 8. Deploy the promoted bot
if program == nil {
program, _ = store.Get(ctx, programID)
}
if program == nil {
return false, fmt.Errorf("program %d not found after gate pass", programID)
}
promCfg := promoter.DefaultConfig()
promCfg.Registry = cfg.Registry
promCfg.RepoDir = cfg.RepoDir
promCfg.KubectlServer = cfg.KubectlServer
promCfg.EncryptionKey = cfg.EncryptionKey
promCfg.RatingThreshold = cfg.RatingThreshold
promCfg.PopCap = cfg.PopCap
p := promoter.New(store, db, promCfg)
promResult, err := p.Promote(ctx, program)
if err != nil {
return false, fmt.Errorf("promote: %w", err)
}
log.Printf(" Promoted: bot_name=%s bot_id=%s endpoint=%s",
promResult.BotName, promResult.BotID, promResult.Endpoint)
// 9. Enforce retirement policy
retired, err := p.EnforcePolicy(ctx)
if err != nil {
log.Printf("warn: retirement policy error: %v", err)
}
if len(retired) > 0 {
log.Printf(" Retired %d bot(s)", len(retired))
}
return true, nil
}
// estimateBehaviorVector analyzes code to estimate aggression/economy/exploration/formation behavior.
func estimateBehaviorVector(code, lang string) []float64 {
// Default to balanced behavior
aggression := 0.5
economy := 0.5
exploration := 0.5
formation := 0.5
codeLower := strings.ToLower(code)
// Aggression indicators
aggressivePatterns := []string{
"attack", "rush", "hunt", "target", "enemy", "combat", "aggress",
"move_toward", "path_to_enemy", "closest_enemy", "attack_radius",
}
aggressiveCount := 0
for _, p := range aggressivePatterns {
aggressiveCount += strings.Count(codeLower, p)
}
// Economy indicators
economyPatterns := []string{
"energy", "collect", "gather", "resource", "pickup", "spawn",
"score", "efficiency", "path_to_energy", "nearest_energy",
}
economyCount := 0
for _, p := range economyPatterns {
economyCount += strings.Count(codeLower, p)
}
// Defensive indicators
defensivePatterns := []string{
"defend", "guard", "protect", "perimeter", "patrol", "safe",
"retreat", "flee", "avoid", "home", "core_defense",
}
defensiveCount := 0
for _, p := range defensivePatterns {
defensiveCount += strings.Count(codeLower, p)
}
// Exploration indicators
explorationPatterns := []string{
"explore", "scout", "scan", "discover", "map", "bfs", "visibility",
"vision", "uncover", "spread",
}
explorationCount := 0
for _, p := range explorationPatterns {
explorationCount += strings.Count(codeLower, p)
}
// Formation indicators
formationPatterns := []string{
"formation", "group", "cluster", "cohesion", "together", "swarm",
"center_of_mass", "rally", "merge", "assemble",
}
formationCount := 0
for _, p := range formationPatterns {
formationCount += strings.Count(codeLower, p)
}
// Normalize and adjust behavior vector
total := aggressiveCount + economyCount + defensiveCount
if total > 0 {
aggression = float64(aggressiveCount) / float64(total)
economy = float64(economyCount) / float64(total+1)
if defensiveCount > aggressiveCount {
aggression = aggression * 0.5
}
}
// Exploration and formation have independent scaling
if explorationCount > 0 {
exploration = clamp(float64(explorationCount)/10.0, 0.1, 0.9)
}
if formationCount > 0 {
formation = clamp(float64(formationCount)/10.0, 0.1, 0.9)
}
// Clamp to [0.1, 0.9] to avoid edge cases
aggression = clamp(aggression, 0.1, 0.9)
economy = clamp(economy, 0.1, 0.9)
return []float64{aggression, economy, exploration, formation}
}
func clamp(v, min, max float64) float64 {
if v < min {
return min
}
if v > max {
return max
}
return v
}
// buildRetryPrompt creates a task prompt that includes error feedback.
func buildRetryPrompt(report *validator.Report, lang string) string {
var failedStage string
var errorMsg string
for _, sr := range report.Stages {
if !sr.Passed {
failedStage = string(sr.Stage)
errorMsg = sr.Error
break
}
}
return fmt.Sprintf(`The previous candidate failed validation at the %s stage with this error:
%s
Please fix this issue and generate an improved bot in %s. The bot must:
1. Have valid syntax that compiles without errors
2. Expose GET /health and POST /turn HTTP endpoints
3. Return JSON in the format {"moves": [{"bot_id": "x", "move": "up|down|left|right|attack"}]}
Focus on fixing the specific error above while maintaining all required functionality.`, failedStage, errorMsg, lang)
}
// exportLive exports the evolution state to live.json.
func exportLive(ctx context.Context, db *sql.DB, cfg RunConfig, verbose bool) {
data, err := live.Export(ctx, db)
if err != nil {
log.Printf("warn: live export failed: %v", err)
return
}
if err := live.WriteFile(data, cfg.LiveExportPath); err != nil {
log.Printf("warn: write live.json: %v", err)
return
}
if cfg.UploadR2 {
r2Cfg := live.R2ConfigFromEnv()
if r2Cfg.HasCredentials() {
r2Client, err := live.NewR2Client(r2Cfg)
if err == nil {
r2Client.UploadLiveJSON(ctx, data)
}
}
}
if verbose {
log.Printf(" Exported live.json (%d programs)", data.TotalPrograms)
}
}
// startRetirementTicker runs periodic retirement checks (§10.8).
// This enforces the 7-day low-rating rule and 50-bot population cap.
func startRetirementTicker(ctx context.Context, db *sql.DB, store *evolverdb.Store, cfg RunConfig, stats *RunStats, verbose bool) {
log.Printf("starting retirement ticker (every %s)", cfg.RetirementCheckInterval)
ticker := time.NewTicker(cfg.RetirementCheckInterval)
defer ticker.Stop()
promCfg := promoter.DefaultConfig()
promCfg.Registry = cfg.Registry
promCfg.RepoDir = cfg.RepoDir
promCfg.KubectlServer = cfg.KubectlServer
promCfg.EncryptionKey = cfg.EncryptionKey
promCfg.RatingThreshold = cfg.RatingThreshold
promCfg.PopCap = cfg.PopCap
promCfg.DeclarativeConfigRepo = cfg.DeclarativeConfigRepo
promCfg.DeclarativeConfigBranch = cfg.DeclarativeConfigBranch
p := promoter.New(store, db, promCfg)
for {
select {
case <-ctx.Done():
log.Printf("stopping retirement ticker")
return
case <-ticker.C:
retired, err := p.EnforcePolicy(ctx)
if err != nil {
log.Printf("retirement ticker error: %v", err)
continue
}
if len(retired) > 0 {
stats.Retired += len(retired)
for _, r := range retired {
if verbose {
log.Printf(" Retired %s (rating %.0f): %s", r.BotID, r.DisplayRating, r.Reason)
}
}
log.Printf("retirement ticker: retired %d bot(s)", len(retired))
}
}
}
}
// startMapEvolutionTicker runs weekly map evolution (§14.6).
// This triggers the acb-map-evolver to evolve maps based on engagement scores.
func startMapEvolutionTicker(ctx context.Context, db *sql.DB, cfg RunConfig, verbose bool) {
schedule := cfg.MapEvolutionSchedule
log.Printf("starting map evolution ticker (schedule: %s %02d:%02d UTC)",
schedule.Weekday, schedule.Hour, schedule.Minute)
// Calculate first scheduled run time
nextRun := nextMapEvolutionTime(schedule)
log.Printf("map evolution: first run scheduled for %s (in %v)",
nextRun.Format(time.RFC3339), time.Until(nextRun).Round(time.Second))
for {
// Sleep until the scheduled time
waitDuration := time.Until(nextRun)
if waitDuration > 0 {
select {
case <-ctx.Done():
log.Printf("stopping map evolution ticker")
return
case <-time.After(waitDuration):
}
}
// Run map evolution
log.Printf("map evolution: starting weekly map evolution run")
if err := runMapEvolution(ctx, db, verbose); err != nil {
log.Printf("map evolution: error: %v", err)
} else {
log.Printf("map evolution: weekly run complete")
}
// Calculate next scheduled run (7 days later)
nextRun = nextRun.Add(7 * 24 * time.Hour)
log.Printf("map evolution: next run scheduled for %s",
nextRun.Format(time.RFC3339))
// Check for cancellation before sleeping again
select {
case <-ctx.Done():
log.Printf("stopping map evolution ticker")
return
default:
}
}
}
// nextMapEvolutionTime calculates the next occurrence of the map evolution schedule.
func nextMapEvolutionTime(schedule WeeklySchedule) time.Time {
now := time.Now().UTC()
// Start with today at the scheduled time
scheduled := time.Date(now.Year(), now.Month(), now.Day(),
schedule.Hour, schedule.Minute, 0, 0, time.UTC)
// Check if we're on the correct weekday
daysUntil := int(schedule.Weekday) - int(now.Weekday())
if daysUntil < 0 {
daysUntil += 7
}
// Add the days until the scheduled weekday
scheduled = scheduled.AddDate(0, 0, daysUntil)
// If the scheduled time has already passed today, move to next week
if scheduled.Before(now) || scheduled.Equal(now) {
scheduled = scheduled.Add(7 * 24 * time.Hour)
}
return scheduled
}
// runMapEvolution executes the map evolution by running the acb-map-evolver binary
// with the --once flag to trigger a single evolution run for all player counts.
func runMapEvolution(ctx context.Context, db *sql.DB, verbose bool) error {
// Path to acb-map-evolver binary (built into same container)
const mapEvolverBin = "/app/acb-map-evolver"
// Verify binary exists
if _, err := os.Stat(mapEvolverBin); err != nil {
return fmt.Errorf("acb-map-evolver binary not found at %s: %w", mapEvolverBin, err)
}
// Prepare environment with database URL
cmdEnv := append(os.Environ(),
fmt.Sprintf("ACB_DATABASE_URL=%s", os.Getenv("ACB_DATABASE_URL")),
)
cmd := exec.CommandContext(ctx, mapEvolverBin, "--once")
cmd.Env = cmdEnv
if verbose {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
log.Printf("map evolution: executing %s --once", mapEvolverBin)
}
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("acb-map-evolver failed: %w, output: %s", err, string(output))
}
if verbose && len(output) > 0 {
log.Printf("map evolution: %s", string(output))
}
return nil
}
// printStats displays evolution loop statistics.
func printStats(stats *RunStats) {
elapsed := time.Since(stats.StartTime)
log.Printf("=== Evolution Loop Stats ===")
log.Printf(" Cycles: %d (%.1f/min)", stats.Cycles, float64(stats.Cycles)/elapsed.Minutes())
log.Printf(" Generated: %d", stats.Generated)
log.Printf(" Validated: %d", stats.Validated)
log.Printf(" Evaluated: %d", stats.Evaluated)
log.Printf(" Promoted: %d", stats.Promoted)
log.Printf(" Retired: %d", stats.Retired)
log.Printf(" Cross-pollinated: %d", stats.CrossPollinated)
log.Printf(" Errors: %d", stats.Errors)
log.Printf(" Uptime: %v", elapsed.Round(time.Second))
}