From 181e846d8a6e6d62208cda9720ed702b7367a843 Mon Sep 17 00:00:00 2001 From: jedarden Date: Sat, 2 May 2026 08:05:51 -0400 Subject: [PATCH] feat(map-evolver): bootstrap empty maps table and containerize - Add seedIfEmpty: idempotent startup seeding (20 maps per player count, ON CONFLICT DO NOTHING) using cellular-automata generation + validate() - Add continuous evolution loop across all player counts (2/3/4/6) - ACB_MIN_SEED_COUNT and ACB_EVOLUTION_PERIOD configurable via env vars - Add Dockerfile (lean Alpine build, no language runtimes) - Add acb-map-evolver to acb-build.yml CI pipeline - Add staging K8s Deployment manifest Co-Authored-By: Claude Sonnet 4.6 --- cmd/acb-map-evolver/Dockerfile | 29 ++ cmd/acb-map-evolver/main.go | 342 +++++++++++++++++++++-- manifests/acb-build.yml | 11 + manifests/acb-map-evolver-deployment.yml | 66 +++++ 4 files changed, 423 insertions(+), 25 deletions(-) create mode 100644 cmd/acb-map-evolver/Dockerfile create mode 100644 manifests/acb-map-evolver-deployment.yml diff --git a/cmd/acb-map-evolver/Dockerfile b/cmd/acb-map-evolver/Dockerfile new file mode 100644 index 0000000..ad9986b --- /dev/null +++ b/cmd/acb-map-evolver/Dockerfile @@ -0,0 +1,29 @@ +FROM golang:1.25-alpine AS builder + +WORKDIR /build + +RUN apk --no-cache add git + +COPY go.mod go.sum ./ +RUN go mod download + +COPY engine/ ./engine/ +COPY cmd/acb-map-evolver/ ./cmd/acb-map-evolver/ + +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /acb-map-evolver ./cmd/acb-map-evolver + +FROM alpine:3.21 + +RUN apk --no-cache add ca-certificates tzdata + +RUN addgroup -g 1000 acb && adduser -D -u 1000 -G acb acb + +COPY --from=builder /acb-map-evolver /app/acb-map-evolver + +USER acb + +# ACB_DATABASE_URL - PostgreSQL connection string (required) +# ACB_MIN_SEED_COUNT - Maps to seed per player count on startup [default: 20] +# ACB_EVOLUTION_PERIOD - Sleep between evolution cycles [default: 30m] + +ENTRYPOINT ["/app/acb-map-evolver"] diff --git a/cmd/acb-map-evolver/main.go b/cmd/acb-map-evolver/main.go index ece67be..acdd6f3 100644 --- a/cmd/acb-map-evolver/main.go +++ b/cmd/acb-map-evolver/main.go @@ -26,6 +26,8 @@ type Config struct { MinEngagement float64 MaxAttempts int ValidateSmoke bool + MinSeedCount int + EvolutionPeriod time.Duration } // Map represents a game map. @@ -62,6 +64,9 @@ type ParentMap struct { VoteMult float64 } +// allPlayerCounts are the valid player counts the matchmaker supports. +var allPlayerCounts = []int{2, 3, 4, 6} + func main() { cfg := parseConfig() if cfg == nil { @@ -74,30 +79,57 @@ func main() { } defer db.Close() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) - defer cancel() - - // Run evolution evolver := NewMapEvolver(db, cfg) - results, err := evolver.Run(ctx) - if err != nil { - log.Fatalf("Evolution failed: %v", err) - } - log.Printf("Evolution complete: %d new maps created", len(results)) - for _, m := range results { - log.Printf(" - %s (engagement: %.2f)", m.ID, m.WallDensity) + // Seed the maps table on startup before entering the evolution loop. + // This is idempotent: it only generates maps when the count for a given + // player count is below MinSeedCount. + seedCtx, seedCancel := context.WithTimeout(context.Background(), 10*time.Minute) + for _, pc := range allPlayerCounts { + if err := evolver.seedIfEmpty(seedCtx, pc); err != nil { + log.Printf("warn: seed player_count=%d: %v", pc, err) + } + } + seedCancel() + + log.Printf("map-evolver: entering continuous evolution loop (period=%s)", cfg.EvolutionPeriod) + + for { + for _, pc := range allPlayerCounts { + cfg.PlayerCount = pc + iterCtx, iterCancel := context.WithTimeout(context.Background(), 10*time.Minute) + results, err := evolver.Run(iterCtx) + iterCancel() + if err != nil { + log.Printf("evolution error player_count=%d: %v", pc, err) + continue + } + log.Printf("player_count=%d: %d new maps created", pc, len(results)) + } + time.Sleep(cfg.EvolutionPeriod) } } func parseConfig() *Config { cfg := &Config{ - DatabaseURL: os.Getenv("ACB_DATABASE_URL"), - PlayerCount: 2, - NumOffspring: 5, - MinEngagement: 5.0, - MaxAttempts: 10, - ValidateSmoke: true, + DatabaseURL: os.Getenv("ACB_DATABASE_URL"), + PlayerCount: 2, + NumOffspring: 5, + MinEngagement: 5.0, + MaxAttempts: 10, + ValidateSmoke: true, + MinSeedCount: 20, + EvolutionPeriod: 30 * time.Minute, + } + + // Allow env var overrides before flag parsing. + if v := os.Getenv("ACB_MIN_SEED_COUNT"); v != "" { + fmt.Sscanf(v, "%d", &cfg.MinSeedCount) + } + if v := os.Getenv("ACB_EVOLUTION_PERIOD"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + cfg.EvolutionPeriod = d + } } for i, arg := range os.Args[1:] { @@ -114,6 +146,16 @@ func parseConfig() *Config { if i+1 < len(os.Args[1:]) { fmt.Sscanf(os.Args[1:][i+1], "%f", &cfg.MinEngagement) } + case "--min-seed-count": + if i+1 < len(os.Args[1:]) { + fmt.Sscanf(os.Args[1:][i+1], "%d", &cfg.MinSeedCount) + } + case "--evolution-period": + if i+1 < len(os.Args[1:]) { + if d, err := time.ParseDuration(os.Args[1:][i+1]); err == nil { + cfg.EvolutionPeriod = d + } + } case "--dry-run": cfg.DryRun = true case "--no-smoke": @@ -122,12 +164,14 @@ func parseConfig() *Config { fmt.Println("Usage: acb-map-evolver [options]") fmt.Println("") fmt.Println("Options:") - fmt.Println(" --player-count N Player count tier (2, 3, 4, or 6) [default: 2]") - fmt.Println(" --num-offspring N Number of maps to create [default: 5]") - fmt.Println(" --min-engagement F Minimum engagement threshold for parents [default: 5.0]") - fmt.Println(" --dry-run Generate maps but don't save to database") - fmt.Println(" --no-smoke Skip smoke-test validation") - fmt.Println(" --help Show this help") + fmt.Println(" --player-count N Player count tier (2, 3, 4, or 6) [default: 2]") + fmt.Println(" --num-offspring N Number of maps to breed per iteration [default: 5]") + fmt.Println(" --min-engagement F Minimum engagement threshold for parents [default: 5.0]") + fmt.Println(" --min-seed-count N Seed this many maps per player count on startup [default: 20]") + fmt.Println(" --evolution-period D Sleep duration between evolution cycles [default: 30m]") + fmt.Println(" --dry-run Generate maps but don't save to database") + fmt.Println(" --no-smoke Skip smoke-test validation") + fmt.Println(" --help Show this help") return nil } } @@ -155,15 +199,22 @@ func NewMapEvolver(db *sql.DB, cfg *Config) *MapEvolver { } } -// Run executes the evolution pipeline. +// Run executes the evolution pipeline for cfg.PlayerCount. func (e *MapEvolver) Run(ctx context.Context) ([]*Map, error) { + // Ensure the table is seeded before attempting to select parents. + if err := e.seedIfEmpty(ctx, e.cfg.PlayerCount); err != nil { + return nil, fmt.Errorf("seeding player_count=%d: %w", e.cfg.PlayerCount, err) + } + // 1. Select parent maps parents, err := e.selectParents(ctx) if err != nil { return nil, fmt.Errorf("selecting parents: %w", err) } if len(parents) < 2 { - return nil, fmt.Errorf("need at least 2 parent maps, found %d", len(parents)) + log.Printf("player_count=%d: only %d parent maps available, skipping evolution cycle", + e.cfg.PlayerCount, len(parents)) + return nil, nil } log.Printf("Selected %d parent maps", len(parents)) @@ -837,6 +888,58 @@ func (e *MapEvolver) canReach(m *Map, start, end Position) bool { } // saveMap stores a map in the database. +// seedIfEmpty generates MinSeedCount random maps for playerCount if the table +// has fewer than that many active/probation rows. Idempotent: safe to call on +// every startup regardless of current state. +func (e *MapEvolver) seedIfEmpty(ctx context.Context, playerCount int) error { + var count int + err := e.db.QueryRowContext(ctx, + `SELECT count(*) FROM maps WHERE player_count = $1 AND status != 'retired'`, + playerCount, + ).Scan(&count) + if err != nil { + return fmt.Errorf("counting maps: %w", err) + } + if count >= e.cfg.MinSeedCount { + return nil + } + + needed := e.cfg.MinSeedCount - count + log.Printf("seeding %d maps for player_count=%d (have %d, want %d)", + needed, playerCount, count, e.cfg.MinSeedCount) + + rows, cols := gridForPlayers(playerCount) + inserted := 0 + for inserted < needed { + m := generateMap(playerCount, rows, cols, 0.15, 20, e.rng) + if m == nil || !e.validate(m) { + continue + } + m.ID = generateMapID(e.rng) + if err := e.saveMapIdempotent(ctx, m); err != nil { + log.Printf("warn: failed to seed map: %v", err) + } else { + inserted++ + } + } + return nil +} + +// saveMapIdempotent inserts a map, ignoring conflicts on map_id. +func (e *MapEvolver) saveMapIdempotent(ctx context.Context, m *Map) error { + mapJSON, err := json.Marshal(m) + if err != nil { + return err + } + _, err = e.db.ExecContext(ctx, ` + INSERT INTO maps (map_id, player_count, status, engagement, wall_density, energy_count, grid_width, grid_height, map_json) + VALUES ($1, $2, 'active', 0, $3, $4, $5, $6, $7) + ON CONFLICT (map_id) DO NOTHING`, + m.ID, m.Players, m.WallDensity, len(m.EnergyNodes), m.Cols, m.Rows, mapJSON, + ) + return err +} + func (e *MapEvolver) saveMap(ctx context.Context, m *Map) error { mapJSON, err := json.Marshal(m) if err != nil { @@ -861,6 +964,195 @@ func (e *MapEvolver) saveMap(ctx context.Context, m *Map) error { return err } +// gridForPlayers returns default grid dimensions for a given player count. +func gridForPlayers(n int) (rows, cols int) { + if n <= 2 { + return 60, 60 + } + side := int(math.Sqrt(float64(2000 * n))) + if side < 40 { + side = 40 + } + if side > 200 { + side = 200 + } + return side, side +} + +// generateMap creates a random symmetric map using cellular-automata wall generation. +// Returns nil if a connected map cannot be produced within maxAttempts tries. +func generateMap(numPlayers, rows, cols int, wallDensity float64, numEnergyNodes int, rng *rand.Rand) *Map { + const maxAttempts = 20 + for attempt := 0; attempt < maxAttempts; attempt++ { + m := generateMapOnce(numPlayers, rows, cols, wallDensity, numEnergyNodes, rng) + if m != nil { + return m + } + } + return nil +} + +func generateMapOnce(numPlayers, rows, cols int, wallDensity float64, numEnergyNodes int, rng *rand.Rand) *Map { + m := &Map{ + Players: numPlayers, + Rows: rows, + Cols: cols, + WallDensity: wallDensity, + Walls: make([]Position, 0), + Cores: make([]Core, 0), + EnergyNodes: make([]Position, 0), + } + + wrap := func(r, c int) Position { + return Position{Row: ((r % rows) + rows) % rows, Col: ((c % cols) + cols) % cols} + } + + centerRow, centerCol := rows/2, cols/2 + + // Place cores with rotational symmetry. + for p := 0; p < numPlayers; p++ { + angle := float64(p) * 2.0 * math.Pi / float64(numPlayers) + r := centerRow + int(float64(centerRow)*0.35*math.Cos(angle)) + c := centerCol + int(float64(centerCol)*0.35*math.Sin(angle)) + m.Cores = append(m.Cores, Core{Position: wrap(r, c), Owner: p}) + } + + // Place energy nodes with rotational symmetry. + used := make(PositionSet) + for _, core := range m.Cores { + used[core.Position] = true + } + nodesPerSector := numEnergyNodes / numPlayers + for i := 0; i < nodesPerSector; i++ { + for attempt := 0; attempt < 100; attempt++ { + angle := rng.Float64() * 2.0 * math.Pi / float64(numPlayers) + radius := 0.2 + rng.Float64()*0.5 + r := centerRow + int(float64(centerRow)*radius*math.Cos(angle)) + c := centerCol + int(float64(centerCol)*radius*math.Sin(angle)) + pos := wrap(r, c) + if used[pos] { + continue + } + used[pos] = true + for p := 0; p < numPlayers; p++ { + rotAngle := angle + float64(p)*2.0*math.Pi/float64(numPlayers) + rr := centerRow + int(float64(centerRow)*radius*math.Cos(rotAngle)) + rc := centerCol + int(float64(centerCol)*radius*math.Sin(rotAngle)) + m.EnergyNodes = append(m.EnergyNodes, wrap(rr, rc)) + } + break + } + } + + // Build protected set around cores and energy nodes. + protected := make(PositionSet) + for _, core := range m.Cores { + for dr := -3; dr <= 3; dr++ { + for dc := -3; dc <= 3; dc++ { + protected[wrap(core.Position.Row+dr, core.Position.Col+dc)] = true + } + } + } + for _, en := range m.EnergyNodes { + for dr := -1; dr <= 1; dr++ { + for dc := -1; dc <= 1; dc++ { + protected[wrap(en.Row+dr, en.Col+dc)] = true + } + } + } + + // Seed grid at ~40% random fill. + grid := make([][]bool, rows) + for r := 0; r < rows; r++ { + grid[r] = make([]bool, cols) + for c := 0; c < cols; c++ { + if !protected[Position{Row: r, Col: c}] && rng.Float64() < 0.40 { + grid[r][c] = true + } + } + } + + // Cellular automata smoothing (4 iterations). + for iter := 0; iter < 4; iter++ { + next := make([][]bool, rows) + for r := 0; r < rows; r++ { + next[r] = make([]bool, cols) + for c := 0; c < cols; c++ { + if protected[Position{Row: r, Col: c}] { + continue + } + n := 0 + for nr := -1; nr <= 1; nr++ { + for nc := -1; nc <= 1; nc++ { + if nr == 0 && nc == 0 { + continue + } + rr := ((r+nr)%rows + rows) % rows + cc := ((c+nc)%cols + cols) % cols + if grid[rr][cc] { + n++ + } + } + } + if grid[r][c] { + next[r][c] = n >= 4 + } else { + next[r][c] = n >= 5 + } + } + } + grid = next + } + + // Enforce rotational symmetry from sector 0. + sectorAngle := 2.0 * math.Pi / float64(numPlayers) + for r := 0; r < rows; r++ { + for c := 0; c < cols; c++ { + if protected[Position{Row: r, Col: c}] { + continue + } + dr := float64(r - centerRow) + dc := float64(c - centerCol) + angle := math.Atan2(dc, dr) + if angle < 0 { + angle += 2.0 * math.Pi + } + sector := int(angle / sectorAngle) + if sector >= numPlayers { + sector = numPlayers - 1 + } + if sector != 0 { + rotAngle := -float64(sector) * sectorAngle + cosA, sinA := math.Cos(rotAngle), math.Sin(rotAngle) + srcR := int(math.Round(float64(centerRow) + dr*cosA - dc*sinA)) + srcC := int(math.Round(float64(centerCol) + dr*sinA + dc*cosA)) + sr := ((srcR % rows) + rows) % rows + sc := ((srcC % cols) + cols) % cols + grid[r][c] = grid[sr][sc] + } + } + } + + // Collect wall positions and thin to target density. + totalTiles := rows * cols + targetWalls := int(float64(totalTiles) * wallDensity) + var walls []Position + for r := 0; r < rows; r++ { + for c := 0; c < cols; c++ { + if grid[r][c] { + walls = append(walls, Position{Row: r, Col: c}) + } + } + } + if len(walls) > targetWalls { + rng.Shuffle(len(walls), func(i, j int) { walls[i], walls[j] = walls[j], walls[i] }) + walls = walls[:targetWalls] + } + m.Walls = walls + + return m +} + // generateMapID creates a random map ID. func generateMapID(rng *rand.Rand) string { const chars = "abcdefghijklmnopqrstuvwxyz0123456789" diff --git a/manifests/acb-build.yml b/manifests/acb-build.yml index 7259253..1444db9 100644 --- a/manifests/acb-build.yml +++ b/manifests/acb-build.yml @@ -79,6 +79,17 @@ spec: value: cmd/acb-evolver/Dockerfile - name: context value: . + - name: build-map-evolver + template: kaniko-build + dependencies: [test] + arguments: + parameters: + - name: image + value: acb-map-evolver + - name: dockerfile + value: cmd/acb-map-evolver/Dockerfile + - name: context + value: . - name: build-index-builder template: kaniko-build dependencies: [test] diff --git a/manifests/acb-map-evolver-deployment.yml b/manifests/acb-map-evolver-deployment.yml new file mode 100644 index 0000000..7fea975 --- /dev/null +++ b/manifests/acb-map-evolver-deployment.yml @@ -0,0 +1,66 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: acb-map-evolver + namespace: ai-code-battle + labels: + app.kubernetes.io/name: acb-map-evolver + app.kubernetes.io/part-of: ai-code-battle + app.kubernetes.io/component: map-evolver + annotations: + argocd-image-updater.argoproj.io/image-list: app=ronaldraygun/acb-map-evolver + argocd-image-updater.argoproj.io/app.update-strategy: name + argocd-image-updater.argoproj.io/app.allow-tags: 'regexp:^[0-9a-f]{7,}$' + argocd-image-updater.argoproj.io/write-back-method: argocd +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: acb-map-evolver + template: + metadata: + labels: + app.kubernetes.io/name: acb-map-evolver + app.kubernetes.io/part-of: ai-code-battle + app.kubernetes.io/component: map-evolver + annotations: + reloader.stakater.com/auto: "true" + spec: + imagePullSecrets: + - name: docker-hub-registry + containers: + - name: map-evolver + image: ronaldraygun/acb-map-evolver:e5dc3bc + env: + - name: ACB_POSTGRES_USER + valueFrom: + secretKeyRef: + name: acb-app-credentials-acb-app + key: username + - name: ACB_POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: acb-app-credentials-acb-app + key: password + - name: ACB_DATABASE_URL + value: postgresql://$(ACB_POSTGRES_USER):$(ACB_POSTGRES_PASSWORD)@acb-postgres:5432/ai_code_battle?sslmode=disable + - name: ACB_MIN_SEED_COUNT + value: "20" + - name: ACB_EVOLUTION_PERIOD + value: "30m" + resources: + requests: + cpu: "100m" + memory: "128Mi" + limits: + cpu: "500m" + memory: "512Mi" + livenessProbe: + exec: + command: + - pgrep + - -x + - acb-map-evolver + initialDelaySeconds: 60 + periodSeconds: 60 + failureThreshold: 3