Energy node placement now uses a tiered radius distribution: 30% in the contested central zone (0.05-0.20 from center), 40% in the mid-zone (0.20-0.40), and 30% in the home zone (0.40-0.60). Previously nodes were placed uniformly at 0.20-0.70, letting bots farm their home quadrant indefinitely without crossing the midline. After cellular automata wall generation, a 3-wide corridor is carved from each core straight to the map center, plus a 5x5 open arena at the center tile. This creates lanes that funnel bots into contact — replicating the key mechanic that drove frequent fights in the original AI Challenge Ants game, where symmetric food spawning near the midfield forced both colonies to expand outward and collide. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1235 lines
32 KiB
Go
1235 lines
32 KiB
Go
// 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
|
||
}
|
||
|
||
// 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()
|
||
|
||
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,
|
||
}
|
||
|
||
// 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:] {
|
||
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 "--dry-run":
|
||
cfg.DryRun = true
|
||
case "--no-smoke":
|
||
cfg.ValidateSmoke = false
|
||
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(" --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
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|