// Command acb-map-evolver evolves maps through breeding and mutation. // It selects high-engagement parent maps, breeds offspring via crossover, // applies mutations, validates connectivity, and smoke-tests with bots. package main import ( "context" "database/sql" "encoding/json" "fmt" "log" "math" "math/rand" "os" "time" _ "github.com/lib/pq" ) // Config holds command-line configuration. type Config struct { DatabaseURL string PlayerCount int NumOffspring int DryRun bool MinEngagement float64 MaxAttempts int ValidateSmoke bool MinSeedCount int EvolutionPeriod time.Duration Once bool Weekly bool WeeklySchedule WeeklySchedule } // 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 } // Map represents a game map. type Map struct { ID string `json:"id"` Players int `json:"players"` Rows int `json:"rows"` Cols int `json:"cols"` WallDensity float64 `json:"wall_density"` Walls []Position `json:"walls"` Cores []Core `json:"cores"` EnergyNodes []Position `json:"energy_nodes"` } // Position represents a grid coordinate. type Position struct { Row int `json:"row"` Col int `json:"col"` } // Core represents a spawn point. type Core struct { Position Position `json:"position"` Owner int `json:"owner"` } // PositionSet is a set of positions. type PositionSet map[Position]bool // ParentMap represents a parent map with its engagement score. type ParentMap struct { Map *Map Engagement float64 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 { os.Exit(1) } db, err := sql.Open("postgres", cfg.DatabaseURL) if err != nil { log.Fatalf("Failed to connect to database: %v", err) } defer db.Close() evolver := NewMapEvolver(db, cfg) // 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() if cfg.Once { // One-shot mode: run evolution once for all player counts and exit log.Printf("map-evolver: running one-shot evolution for all player counts") totalCreated := 0 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)) totalCreated += len(results) } log.Printf("map-evolver: one-shot evolution complete, %d total maps created", totalCreated) return } if cfg.Weekly { // Weekly mode: run evolution on a weekly schedule log.Printf("map-evolver: entering weekly evolution mode (schedule: %s %02d:%02d UTC)", cfg.WeeklySchedule.Weekday, cfg.WeeklySchedule.Hour, cfg.WeeklySchedule.Minute) runWeeklyLoop(evolver, cfg) return } 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, MinSeedCount: 20, EvolutionPeriod: 30 * time.Minute, WeeklySchedule: WeeklySchedule{ Weekday: time.Sunday, // Default: Sunday Hour: 3, // Default: 03:00 UTC Minute: 0, }, } // 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 } } // Weekly schedule from env (format: "WEEKDAY:HH:MM" e.g., "0:03:00" for Sunday 03:00) if v := os.Getenv("ACB_WEEKLY_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.WeeklySchedule.Weekday = time.Weekday(weekday) cfg.WeeklySchedule.Hour = hour cfg.WeeklySchedule.Minute = minute } } } for i, arg := range os.Args[1:] { switch arg { case "--player-count": if i+1 < len(os.Args[1:]) { fmt.Sscanf(os.Args[1:][i+1], "%d", &cfg.PlayerCount) } case "--num-offspring": if i+1 < len(os.Args[1:]) { fmt.Sscanf(os.Args[1:][i+1], "%d", &cfg.NumOffspring) } case "--min-engagement": 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 "--weekly": cfg.Weekly = true case "--weekly-schedule": if i+1 < len(os.Args[1:]) { // Parse format: "WEEKDAY:HH:MM" e.g., "0:03:00" for Sunday 03:00 var weekday, hour, minute int if _, err := fmt.Sscanf(os.Args[1:][i+1], "%d:%d:%d", &weekday, &hour, &minute); err == nil { if weekday >= 0 && weekday <= 6 && hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 { cfg.WeeklySchedule.Weekday = time.Weekday(weekday) cfg.WeeklySchedule.Hour = hour cfg.WeeklySchedule.Minute = minute } else { log.Printf("Invalid weekly schedule format: %s (expected WEEKDAY:HH:MM, e.g., 0:03:00)", os.Args[1:][i+1]) } } } case "--dry-run": cfg.DryRun = true case "--no-smoke": cfg.ValidateSmoke = false case "--once": cfg.Once = true case "--help", "-h": 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 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(" --weekly Enable weekly automated evolution mode") fmt.Println(" --weekly-schedule S Weekly schedule (WEEKDAY:HH:MM, e.g., 0:03:00 for Sunday 03:00 UTC)") fmt.Println(" Weekday: 0=Sun, 1=Mon, 2=Tue, 3=Wed, 4=Thu, 5=Fri, 6=Sat") fmt.Println(" --dry-run Generate maps but don't save to database") fmt.Println(" --no-smoke Skip smoke-test validation") fmt.Println(" --once Run evolution once for all player counts and exit") fmt.Println(" --help Show this help") fmt.Println("") fmt.Println("Environment variables:") fmt.Println(" ACB_DATABASE_URL PostgreSQL connection string") fmt.Println(" ACB_MIN_SEED_COUNT Minimum maps to seed per player count [default: 20]") fmt.Println(" ACB_EVOLUTION_PERIOD Sleep duration between cycles [default: 30m]") fmt.Println(" ACB_WEEKLY_SCHEDULE Weekly schedule (WEEKDAY:HH:MM) [default: 0:03:00]") return nil } } if cfg.DatabaseURL == "" && !cfg.DryRun { log.Fatal("ACB_DATABASE_URL environment variable is required") } return cfg } // MapEvolver handles map evolution. type MapEvolver struct { db *sql.DB cfg *Config rng *rand.Rand } // NewMapEvolver creates a new map evolver. func NewMapEvolver(db *sql.DB, cfg *Config) *MapEvolver { return &MapEvolver{ db: db, cfg: cfg, rng: rand.New(rand.NewSource(time.Now().UnixNano())), } } // 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 { 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)) // 2. Breed offspring var offspring []*Map for i := 0; i < e.cfg.NumOffspring; i++ { for attempt := 0; attempt < e.cfg.MaxAttempts; attempt++ { child := e.breed(parents) if child == nil { continue } // 3. Validate if !e.validate(child) { continue } // 4. Smoke test (if enabled) if e.cfg.ValidateSmoke && !e.smokeTest(child) { continue } offspring = append(offspring, child) break } } // 5. Save to database if !e.cfg.DryRun { for _, m := range offspring { if err := e.saveMap(ctx, m); err != nil { log.Printf("Failed to save map %s: %v", m.ID, err) } } } return offspring, nil } // selectParents retrieves top maps by engagement × vote multiplier. func (e *MapEvolver) selectParents(ctx context.Context) ([]*ParentMap, error) { query := ` SELECT m.map_id, m.map_json, COALESCE(ms.engagement, 0) as engagement, CASE WHEN COALESCE(votes.net_votes, 0) > 10 THEN 1.5 WHEN COALESCE(votes.net_votes, 0) < 0 THEN 0.5 ELSE 1.0 END as vote_mult FROM maps m LEFT JOIN map_scores ms ON m.map_id = ms.map_id LEFT JOIN ( SELECT map_id, SUM(vote) as net_votes FROM map_votes GROUP BY map_id ) votes ON m.map_id = votes.map_id WHERE m.player_count = $1 AND m.status IN ('active', 'classic') ORDER BY COALESCE(ms.engagement, 0) * CASE WHEN COALESCE(votes.net_votes, 0) > 10 THEN 1.5 WHEN COALESCE(votes.net_votes, 0) < 0 THEN 0.5 ELSE 1.0 END DESC LIMIT 20 ` rows, err := e.db.QueryContext(ctx, query, e.cfg.PlayerCount) if err != nil { return nil, err } defer rows.Close() var parents []*ParentMap for rows.Next() { var id string var mapJSON []byte var engagement float64 var voteMult float64 if err := rows.Scan(&id, &mapJSON, &engagement, &voteMult); err != nil { return nil, err } var m Map if err := json.Unmarshal(mapJSON, &m); err != nil { log.Printf("Failed to unmarshal map %s: %v", id, err) continue } m.ID = id parents = append(parents, &ParentMap{ Map: &m, Engagement: engagement, VoteMult: voteMult, }) } return parents, nil } // breed creates a new map from parent maps via crossover and mutation. func (e *MapEvolver) breed(parents []*ParentMap) *Map { // Weighted random selection based on engagement × vote multiplier p1 := e.selectWeighted(parents) p2 := e.selectWeighted(parents) for p2 == p1 && len(parents) > 1 { p2 = e.selectWeighted(parents) } // Create child from crossover child := e.crossover(p1.Map, p2.Map) // Apply mutations e.mutate(child) // Generate new ID child.ID = generateMapID(e.rng) child.Players = e.cfg.PlayerCount return child } // selectWeighted selects a parent with probability proportional to engagement × vote multiplier. func (e *MapEvolver) selectWeighted(parents []*ParentMap) *ParentMap { totalWeight := 0.0 for _, p := range parents { w := p.Engagement * p.VoteMult if w < 0.1 { w = 0.1 // Minimum weight } totalWeight += w } r := e.rng.Float64() * totalWeight cumulative := 0.0 for _, p := range parents { w := p.Engagement * p.VoteMult if w < 0.1 { w = 0.1 } cumulative += w if r <= cumulative { return p } } return parents[len(parents)-1] } // crossover combines two parent maps into a child. func (e *MapEvolver) crossover(p1, p2 *Map) *Map { child := &Map{ Rows: p1.Rows, Cols: p1.Cols, Players: e.cfg.PlayerCount, WallDensity: (p1.WallDensity + p2.WallDensity) / 2, Walls: make([]Position, 0), Cores: make([]Core, 0), EnergyNodes: make([]Position, 0), } // Use cores from p1 (they should be symmetric anyway) child.Cores = p1.Cores centerRow := child.Rows / 2 centerCol := child.Cols / 2 sectorAngle := 2.0 * math.Pi / float64(child.Players) // Build wall sets walls1 := make(PositionSet) for _, w := range p1.Walls { walls1[w] = true } walls2 := make(PositionSet) for _, w := range p2.Walls { walls2[w] = true } // Crossover: for each position in sector 0, pick wall from p1 or p2 // Then mirror to all sectors for r := 0; r < child.Rows; r++ { for c := 0; c < child.Cols; c++ { dr := float64(r) - float64(centerRow) dc := float64(c) - float64(centerCol) angle := math.Atan2(dc, dr) if angle < 0 { angle += 2.0 * math.Pi } sector := int(angle / sectorAngle) if sector >= child.Players { sector = child.Players - 1 } // Only process sector 0, then mirror if sector != 0 { continue } pos := Position{Row: r, Col: c} isWall := false if walls1[pos] && walls2[pos] { // Both have wall: keep it isWall = true } else if walls1[pos] || walls2[pos] { // One has wall: 50% chance isWall = e.rng.Float64() < 0.5 } if isWall { // Mirror wall to all sectors for s := 0; s < child.Players; s++ { rotAngle := float64(s) * sectorAngle cosA := math.Cos(rotAngle) sinA := math.Sin(rotAngle) rr := int(math.Round(float64(centerRow) + dr*cosA - dc*sinA)) rc := int(math.Round(float64(centerCol) + dr*sinA + dc*cosA)) rr = ((rr % child.Rows) + child.Rows) % child.Rows rc = ((rc % child.Cols) + child.Cols) % child.Cols child.Walls = append(child.Walls, Position{Row: rr, Col: rc}) } } } } // Crossover energy nodes: take from both parents seenNodes := make(PositionSet) for _, en := range p1.EnergyNodes { if !seenNodes[en] { child.EnergyNodes = append(child.EnergyNodes, en) seenNodes[en] = true } } for _, en := range p2.EnergyNodes { if !seenNodes[en] && e.rng.Float64() < 0.5 { child.EnergyNodes = append(child.EnergyNodes, en) seenNodes[en] = true } } // Update wall density child.WallDensity = float64(len(child.Walls)) / float64(child.Rows*child.Cols) return child } // mutate applies random mutations to a map. func (e *MapEvolver) mutate(m *Map) { wallSet := make(PositionSet) for _, w := range m.Walls { wallSet[w] = true } protected := make(PositionSet) for _, core := range m.Cores { for dr := -3; dr <= 3; dr++ { for dc := -3; dc <= 3; dc++ { nr := ((core.Position.Row + dr) % m.Rows + m.Rows) % m.Rows nc := ((core.Position.Col + dc) % m.Cols + m.Cols) % m.Cols protected[Position{Row: nr, Col: nc}] = true } } } for _, en := range m.EnergyNodes { protected[en] = true } // Mutate walls: flip 5-10% of tiles mutationRate := 0.05 + e.rng.Float64()*0.05 centerRow := m.Rows / 2 centerCol := m.Cols / 2 sectorAngle := 2.0 * math.Pi / float64(m.Players) // Collect positions to mutate in sector 0 var toFlip []Position for r := 0; r < m.Rows; r++ { for c := 0; c < m.Cols; c++ { dr := float64(r) - float64(centerRow) dc := float64(c) - float64(centerCol) angle := math.Atan2(dc, dr) if angle < 0 { angle += 2.0 * math.Pi } sector := int(angle / sectorAngle) if sector >= m.Players { sector = m.Players - 1 } if sector != 0 { continue } pos := Position{Row: r, Col: c} if protected[pos] { continue } if e.rng.Float64() < mutationRate { toFlip = append(toFlip, pos) } } } // Apply flips with mirroring for _, pos := range toFlip { isWall := wallSet[pos] // Remove existing walls at all mirrored positions for s := 0; s < m.Players; s++ { dr := float64(pos.Row) - float64(centerRow) dc := float64(pos.Col) - float64(centerCol) rotAngle := float64(s) * sectorAngle cosA := math.Cos(rotAngle) sinA := math.Sin(rotAngle) rr := int(math.Round(float64(centerRow) + dr*cosA - dc*sinA)) rc := int(math.Round(float64(centerCol) + dr*sinA + dc*cosA)) rr = ((rr % m.Rows) + m.Rows) % m.Rows rc = ((rc % m.Cols) + m.Cols) % m.Cols mirrorPos := Position{Row: rr, Col: rc} if isWall { // Remove wall delete(wallSet, mirrorPos) } else { // Add wall wallSet[mirrorPos] = true } } } // Rebuild wall list m.Walls = make([]Position, 0, len(wallSet)) for pos := range wallSet { m.Walls = append(m.Walls, pos) } // Shift 1-3 energy nodes by 1-3 tiles (with symmetry) numShifts := 1 + e.rng.Intn(3) for i := 0; i < numShifts && len(m.EnergyNodes) > 0; i++ { idx := e.rng.Intn(len(m.EnergyNodes)) oldPos := m.EnergyNodes[idx] // Find sector of this node dr := float64(oldPos.Row) - float64(centerRow) dc := float64(oldPos.Col) - float64(centerCol) angle := math.Atan2(dc, dr) if angle < 0 { angle += 2.0 * math.Pi } sector := int(angle / sectorAngle) if sector >= m.Players { sector = m.Players - 1 } // Only shift if in sector 0 if sector != 0 { continue } // Shift by 1-3 tiles in a random direction shiftDist := 1 + e.rng.Intn(3) shiftAngle := e.rng.Float64() * 2 * math.Pi // Remove old position and all mirrors newNodes := make([]Position, 0) nodeSet := make(PositionSet) for _, en := range m.EnergyNodes { nodeSet[en] = true } for s := 0; s < m.Players; s++ { rotAngle := float64(s) * sectorAngle cosA := math.Cos(rotAngle) sinA := math.Sin(rotAngle) rr := int(math.Round(float64(centerRow) + dr*cosA - dc*sinA)) rc := int(math.Round(float64(centerCol) + dr*sinA + dc*cosA)) delete(nodeSet, Position{Row: rr, Col: rc}) } // Calculate new position in sector 0 newR := int(math.Round(float64(oldPos.Row) + float64(shiftDist)*math.Cos(shiftAngle))) newC := int(math.Round(float64(oldPos.Col) + float64(shiftDist)*math.Sin(shiftAngle))) newR = ((newR % m.Rows) + m.Rows) % m.Rows newC = ((newC % m.Cols) + m.Cols) % m.Cols // Add new position and all mirrors newDR := float64(newR) - float64(centerRow) newDC := float64(newC) - float64(centerCol) for s := 0; s < m.Players; s++ { rotAngle := float64(s) * sectorAngle cosA := math.Cos(rotAngle) sinA := math.Sin(rotAngle) rr := int(math.Round(float64(centerRow) + newDR*cosA - newDC*sinA)) rc := int(math.Round(float64(centerCol) + newDR*sinA + newDC*cosA)) rr = ((rr % m.Rows) + m.Rows) % m.Rows rc = ((rc % m.Cols) + m.Cols) % m.Cols newPos := Position{Row: rr, Col: rc} if !wallSet[newPos] { nodeSet[newPos] = true } } for pos := range nodeSet { newNodes = append(newNodes, pos) } m.EnergyNodes = newNodes break // Only one shift per mutation run } // Update wall density m.WallDensity = float64(len(m.Walls)) / float64(m.Rows*m.Cols) // Apply smoothing (2 iterations of cellular automata) e.smoothWalls(m, protected) } // smoothWalls applies cellular automata smoothing to walls. // This is a simplified version that preserves existing walls while allowing // for some natural clustering through the mutation process. func (e *MapEvolver) smoothWalls(m *Map, protected PositionSet) { // For now, skip the full cellular automata smoothing as it's too aggressive // when combined with the mutation. The mutation already provides enough variation. // The full CA smoothing is better used in initial map generation. // Just ensure symmetry is maintained after mutation centerRow := m.Rows / 2 centerCol := m.Cols / 2 sectorAngle := 2.0 * math.Pi / float64(m.Players) // Build wall set wallSet := make(PositionSet) for _, w := range m.Walls { wallSet[w] = true } // Collect walls in sector 0 sector0Walls := make(PositionSet) for pos := range wallSet { dr := float64(pos.Row) - float64(centerRow) dc := float64(pos.Col) - float64(centerCol) angle := math.Atan2(dc, dr) if angle < 0 { angle += 2.0 * math.Pi } sector := int(angle / sectorAngle) if sector >= m.Players { sector = m.Players - 1 } if sector == 0 && !protected[pos] { sector0Walls[pos] = true } } // Rebuild walls from sector 0 with proper mirroring newWallSet := make(PositionSet) for pos := range sector0Walls { dr := float64(pos.Row) - float64(centerRow) dc := float64(pos.Col) - float64(centerCol) // Mirror to all sectors for s := 0; s < m.Players; s++ { rotAngle := float64(s) * sectorAngle cosA := math.Cos(rotAngle) sinA := math.Sin(rotAngle) rr := int(math.Round(float64(centerRow) + dr*cosA - dc*sinA)) rc := int(math.Round(float64(centerCol) + dr*sinA + dc*cosA)) rr = ((rr % m.Rows) + m.Rows) % m.Rows rc = ((rc % m.Cols) + m.Cols) % m.Cols mirrorPos := Position{Row: rr, Col: rc} if !protected[mirrorPos] { newWallSet[mirrorPos] = true } } } m.Walls = make([]Position, 0, len(newWallSet)) for pos := range newWallSet { m.Walls = append(m.Walls, pos) } m.WallDensity = float64(len(m.Walls)) / float64(m.Rows * m.Cols) } // validate checks if a map meets all validation criteria. func (e *MapEvolver) validate(m *Map) bool { // Check wall density bounds if m.WallDensity < 0.05 || m.WallDensity > 0.30 { return false } // Check connectivity if !e.checkConnectivity(m) { return false } // Check open area per player totalTiles := m.Rows * m.Cols wallCount := len(m.Walls) openTiles := totalTiles - wallCount openPerPlayer := openTiles / m.Players if openPerPlayer < 900 || openPerPlayer > 5000 { return false } // Check each core can reach at least 3 energy nodes for _, core := range m.Cores { reachable := e.countReachableEnergyNodes(m, core.Position) if reachable < 3 { return false } } return true } // checkConnectivity verifies all passable tiles are reachable from cores. func (e *MapEvolver) checkConnectivity(m *Map) bool { if len(m.Cores) == 0 { return false } // Build wall set wallSet := make(PositionSet) for _, w := range m.Walls { wallSet[w] = true } // Count passable tiles passable := make(PositionSet) for r := 0; r < m.Rows; r++ { for c := 0; c < m.Cols; c++ { pos := Position{Row: r, Col: c} if !wallSet[pos] { passable[pos] = true } } } // BFS from first core start := m.Cores[0].Position if wallSet[start] { return false } visited := make(PositionSet) queue := []Position{start} visited[start] = true count := 1 dirs := []Position{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} for len(queue) > 0 { curr := queue[0] queue = queue[1:] for _, d := range dirs { nr := ((curr.Row + d.Row) % m.Rows + m.Rows) % m.Rows nc := ((curr.Col + d.Col) % m.Cols + m.Cols) % m.Cols np := Position{Row: nr, Col: nc} if passable[np] && !visited[np] { visited[np] = true queue = append(queue, np) count++ } } } return count == len(passable) } // countReachableEnergyNodes counts energy nodes reachable from a starting position. func (e *MapEvolver) countReachableEnergyNodes(m *Map, start Position) int { wallSet := make(PositionSet) for _, w := range m.Walls { wallSet[w] = true } energySet := make(PositionSet) for _, en := range m.EnergyNodes { energySet[en] = true } visited := make(PositionSet) queue := []Position{start} visited[start] = true count := 0 dirs := []Position{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} for len(queue) > 0 { curr := queue[0] queue = queue[1:] if energySet[curr] { count++ } for _, d := range dirs { nr := ((curr.Row + d.Row) % m.Rows + m.Rows) % m.Rows nc := ((curr.Col + d.Col) % m.Cols + m.Cols) % m.Cols np := Position{Row: nr, Col: nc} if !wallSet[np] && !visited[np] { visited[np] = true queue = append(queue, np) } } } return count } // smokeTest runs quick matches to verify the map produces reasonable engagement. func (e *MapEvolver) smokeTest(m *Map) bool { // For now, use a simplified check: verify the map has reasonable properties // A full smoke test would run 3 matches with built-in bots // Check that map has enough energy nodes minEnergy := m.Players * 3 if len(m.EnergyNodes) < minEnergy { return false } // Check that walls don't block paths between cores for i, core1 := range m.Cores { for j, core2 := range m.Cores { if i >= j { continue } if !e.canReach(m, core1.Position, core2.Position) { return false } } } return true } // canReach checks if two positions are reachable from each other. func (e *MapEvolver) canReach(m *Map, start, end Position) bool { wallSet := make(PositionSet) for _, w := range m.Walls { wallSet[w] = true } visited := make(PositionSet) queue := []Position{start} visited[start] = true dirs := []Position{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} for len(queue) > 0 { curr := queue[0] queue = queue[1:] if curr == end { return true } for _, d := range dirs { nr := ((curr.Row + d.Row) % m.Rows + m.Rows) % m.Rows nc := ((curr.Col + d.Col) % m.Cols + m.Cols) % m.Cols np := Position{Row: nr, Col: nc} if !wallSet[np] && !visited[np] { visited[np] = true queue = append(queue, np) } } } return false } // 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 { return err } query := ` 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) ` _, err = e.db.ExecContext(ctx, query, m.ID, m.Players, m.WallDensity, len(m.EnergyNodes), m.Cols, m.Rows, mapJSON, ) 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) // Tiered radius: bias toward center to force contested energy collection. // 30% central (forces both players to midfield), 40% mid, 30% home. var radius float64 switch { case i < nodesPerSector*3/10: radius = 0.05 + rng.Float64()*0.15 // 0.05–0.20: contested central zone case i < nodesPerSector*7/10: radius = 0.20 + rng.Float64()*0.20 // 0.20–0.40: mid-zone default: radius = 0.40 + rng.Float64()*0.20 // 0.40–0.60: home zone } 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] } } } // Carve corridors from each core to the map center. // Creates 3-wide lanes that funnel bots into contact at midfield. for _, core := range m.Cores { carveCorridor(grid, core.Position.Row, core.Position.Col, centerRow, centerCol, rows, cols) } // Open a 5×5 arena at the center so all corridors connect. for dr := -2; dr <= 2; dr++ { for dc := -2; dc <= 2; dc++ { rr := ((centerRow+dr)%rows + rows) % rows cc := ((centerCol+dc)%cols + cols) % cols grid[rr][cc] = false } } // 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 } // carveCorridor opens a 3-wide path from (r0,c0) to (r1,c1) using integer stepping. // Perpendicular width is 1 tile on each side of the center line. func carveCorridor(grid [][]bool, r0, c0, r1, c1, rows, cols int) { dr := r1 - r0 dc := c1 - c0 steps := dr if steps < 0 { steps = -steps } if dc < 0 && -dc > steps { steps = -dc } else if dc > steps { steps = dc } if steps == 0 { return } horizontal := dc < 0 && -dc > (dr+1) || dc > 0 && dc > (dr+1) // wider in col direction if dr < 0 { dr = -dr } // recompute: use originals origDR := r1 - r0 origDC := c1 - c0 for step := 0; step <= steps; step++ { r := r0 + origDR*step/steps c := c0 + origDC*step/steps // Widen perpendicular to primary movement direction if !horizontal { // Mostly vertical: widen horizontally for wc := -1; wc <= 1; wc++ { rr := ((r)%rows + rows) % rows cc := ((c+wc)%cols + cols) % cols grid[rr][cc] = false } } else { // Mostly horizontal: widen vertically for wr := -1; wr <= 1; wr++ { rr := ((r+wr)%rows + rows) % rows cc := ((c)%cols + cols) % cols grid[rr][cc] = false } } } _ = dr // suppress unused warning } // generateMapID creates a random map ID. func generateMapID(rng *rand.Rand) string { const chars = "abcdefghijklmnopqrstuvwxyz0123456789" b := make([]byte, 8) for i := range b { b[i] = chars[rng.Intn(len(chars))] } return "map_" + string(b) } // runWeeklyLoop runs map evolution on a weekly schedule. // It waits until the next scheduled time (weekday:hour:minute UTC), then runs // evolution for all player counts, and repeats every 7 days. func runWeeklyLoop(evolver *MapEvolver, cfg *Config) { schedule := cfg.WeeklySchedule // Calculate first scheduled run time nextRun := nextScheduledTime(schedule) log.Printf("map-evolver: first weekly 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 { log.Printf("map-evolver: sleeping %v until next scheduled run at %s", waitDuration.Round(time.Second), nextRun.Format(time.RFC3339)) time.Sleep(waitDuration) } // Run evolution for all player counts log.Printf("map-evolver: starting weekly evolution run for all player counts") totalCreated := 0 for _, pc := range allPlayerCounts { evolver.cfg.PlayerCount = pc ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) results, err := evolver.Run(ctx) cancel() if err != nil { log.Printf("map-evolver: player_count=%d error: %v", pc, err) continue } log.Printf("map-evolver: player_count=%d: %d new maps created", pc, len(results)) totalCreated += len(results) } log.Printf("map-evolver: weekly evolution run complete, %d total maps created", totalCreated) // Calculate next scheduled run (7 days later) nextRun = nextRun.Add(7 * 24 * time.Hour) log.Printf("map-evolver: next weekly run scheduled for %s", nextRun.Format(time.RFC3339)) } } // nextScheduledTime calculates the next occurrence of the weekly schedule. // If the current time is before the scheduled time today, it returns today's time. // If the current time is after the scheduled time today, it returns next week's time. func nextScheduledTime(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 }