ai-code-battle/cmd/acb-mapgen/main.go
jedarden 0f7d55c5d4 Fix sin/cos math bug and add cellular automata map generation
Replace broken Taylor series sin/cos approximations with math.Sin/math.Cos
in both engine/match.go and cmd/acb-mapgen. The Taylor series produced
incorrect results for angles > π, causing wrong positions in 3+ player maps.

Upgrade map generator wall placement from random scatter to cellular
automata (B5/S4 rule, 4 iterations) with rotational symmetry enforcement
and connectivity validation. Add comprehensive mapgen tests and dominance
win condition tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 01:10:46 -04:00

315 lines
9.1 KiB
Go

// Command acb-mapgen generates symmetric maps for AI Code Battle.
package main
import (
"encoding/json"
"flag"
"fmt"
"math"
"math/rand"
"os"
"time"
)
// Map represents a generated 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"`
Generated time.Time `json:"generated"`
}
// 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"`
}
func main() {
// Command-line flags
players := flag.Int("players", 2, "Number of players (2, 3, 4, or 6)")
rows := flag.Int("rows", 60, "Grid rows")
cols := flag.Int("cols", 60, "Grid columns")
wallDensity := flag.Float64("wall-density", 0.15, "Wall density (0.0-0.3)")
energyNodes := flag.Int("energy-nodes", 20, "Energy nodes")
seed := flag.Int64("seed", time.Now().UnixNano(), "Random seed")
output := flag.String("output", "", "Output file (default: stdout)")
maxAttempts := flag.Int("max-attempts", 100, "Max attempts to generate a connected map")
help := flag.Bool("help", false, "Show help")
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "Usage: acb-mapgen [options]\n\n")
fmt.Fprintf(flag.CommandLine.Output(), "Generate a symmetric map for AI Code Battle.\n\n")
fmt.Fprintf(flag.CommandLine.Output(), "The generator ensures all passable tiles are reachable from\n")
fmt.Fprintf(flag.CommandLine.Output(), "any core (full connectivity guarantee).\n\n")
fmt.Fprintf(flag.CommandLine.Output(), "Symmetry types:\n")
fmt.Fprintf(flag.CommandLine.Output(), " 2 players: 180° rotational\n")
fmt.Fprintf(flag.CommandLine.Output(), " 3 players: 120° rotational\n")
fmt.Fprintf(flag.CommandLine.Output(), " 4 players: 90° rotational\n")
fmt.Fprintf(flag.CommandLine.Output(), " 6 players: 60° rotational\n\n")
fmt.Fprintf(flag.CommandLine.Output(), "Options:\n")
flag.PrintDefaults()
}
flag.Parse()
if *help {
flag.Usage()
os.Exit(0)
}
// Validate player count
validPlayers := map[int]bool{2: true, 3: true, 4: true, 6: true}
if !validPlayers[*players] {
fmt.Fprintf(os.Stderr, "Error: invalid player count %d (must be 2, 3, 4, or 6)\n", *players)
os.Exit(1)
}
// Validate wall density
if *wallDensity < 0.05 || *wallDensity > 0.30 {
fmt.Fprintf(os.Stderr, "Error: wall density must be between 0.05 and 0.30\n")
os.Exit(1)
}
// Generate map with connectivity validation
rng := rand.New(rand.NewSource(*seed))
m := EnsureConnectivity(*players, *rows, *cols, *wallDensity, *energyNodes, rng, *maxAttempts)
if m == nil {
fmt.Fprintf(os.Stderr, "Error: failed to generate a connected map after %d attempts\n", *maxAttempts)
fmt.Fprintf(os.Stderr, "Try reducing wall density or increasing max-attempts\n")
os.Exit(1)
}
// Generate map ID
m.ID = generateMapID(rng)
m.Generated = time.Now().UTC()
// Output
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to marshal map: %v\n", err)
os.Exit(1)
}
if *output != "" {
if err := os.WriteFile(*output, data, 0644); err != nil {
fmt.Fprintf(os.Stderr, "Error: failed to write file: %v\n", err)
os.Exit(1)
}
fmt.Printf("Map written to %s\n", *output)
} else {
fmt.Println(string(data))
}
}
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)
}
func generateMap(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),
}
centerRow := rows / 2
centerCol := cols / 2
// Helper to wrap position
wrap := func(r, c int) Position {
r = ((r % rows) + rows) % rows
c = ((c % cols) + cols) % cols
return Position{Row: r, Col: c}
}
// Generate cores with rotational symmetry
for p := 0; p < numPlayers; p++ {
angle := float64(p) * 2.0 * math.Pi / float64(numPlayers)
radius := 0.35 // 35% from center
r := centerRow + int(float64(centerRow)*radius*math.Cos(angle))
c := centerCol + int(float64(centerCol)*radius*math.Sin(angle))
m.Cores = append(m.Cores, Core{
Position: wrap(r, c),
Owner: p,
})
}
// Generate energy nodes with rotational symmetry
nodesPerSector := numEnergyNodes / numPlayers
usedPositions := make(map[Position]bool)
// Mark core positions as used
for _, c := range m.Cores {
usedPositions[c.Position] = true
}
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 // 20-70% from center
r := centerRow + int(float64(centerRow)*radius*math.Cos(angle))
c := centerCol + int(float64(centerCol)*radius*math.Sin(angle))
pos := wrap(r, c)
if !usedPositions[pos] {
usedPositions[pos] = true
// Mirror for all players
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
}
}
}
// Generate walls using cellular automata for natural-looking structures.
// Algorithm: seed the full grid, run automata to form clusters,
// enforce rotational symmetry by copying sector 0 to all sectors,
// then thin to target density.
// Build a set of protected positions (cores, energy nodes, and neighbors)
protected := make(map[Position]bool)
clearRadius := 3
for _, core := range m.Cores {
for dr := -clearRadius; dr <= clearRadius; dr++ {
for dc := -clearRadius; dc <= clearRadius; 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
}
}
}
// Step 1: Seed full 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
}
}
}
// Step 2: Run cellular automata smoothing (4 iterations)
// Rule: birth at >= 5 wall neighbors, survive at >= 4
for iter := 0; iter < 4; iter++ {
newGrid := make([][]bool, rows)
for r := 0; r < rows; r++ {
newGrid[r] = make([]bool, cols)
for c := 0; c < cols; c++ {
if protected[Position{Row: r, Col: c}] {
continue
}
neighbors := 0
for ndr := -1; ndr <= 1; ndr++ {
for ndc := -1; ndc <= 1; ndc++ {
if ndr == 0 && ndc == 0 {
continue
}
nr := ((r + ndr) % rows + rows) % rows
nc := ((c + ndc) % cols + cols) % cols
if grid[nr][nc] {
neighbors++
}
}
}
if grid[r][c] {
newGrid[r][c] = neighbors >= 4
} else {
newGrid[r][c] = neighbors >= 5
}
}
}
grid = newGrid
}
// Step 3: Enforce rotational symmetry by reading from sector 0
sectorAngle := 2.0 * math.Pi / float64(numPlayers)
symGrid := make([][]bool, rows)
for r := 0; r < rows; r++ {
symGrid[r] = make([]bool, cols)
for c := 0; c < cols; c++ {
if protected[Position{Row: r, Col: c}] {
continue
}
// Find the canonical position in sector 0
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 >= numPlayers {
sector = numPlayers - 1
}
if sector == 0 {
symGrid[r][c] = grid[r][c]
} else {
// Rotate back to sector 0
rotAngle := -float64(sector) * sectorAngle
cosA := math.Cos(rotAngle)
sinA := 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
symGrid[r][c] = grid[sr][sc]
}
}
}
// Step 4: Thin to target density if needed
totalTiles := rows * cols
targetWalls := int(float64(totalTiles) * wallDensity)
var wallPositions []Position
for r := 0; r < rows; r++ {
for c := 0; c < cols; c++ {
if symGrid[r][c] {
wallPositions = append(wallPositions, Position{Row: r, Col: c})
}
}
}
if len(wallPositions) > targetWalls {
rng.Shuffle(len(wallPositions), func(i, j int) {
wallPositions[i], wallPositions[j] = wallPositions[j], wallPositions[i]
})
wallPositions = wallPositions[:targetWalls]
}
m.Walls = wallPositions
return m
}