ai-code-battle/cmd/acb-mapgen/main.go
jedarden 890785c5c4 Complete Phase 1: add connectivity validation and determinism tests
- Add connectivity.go: BFS-based map connectivity validation with retry
- Update mapgen to use connectivity checking by default
- Add determinism_test.go: property-based tests for reproducibility
  - Same seed produces identical replays
  - Turn execution is deterministic
  - Grid operations are deterministic
  - Combat resolution is deterministic
  - Full 500-turn match validation
- All 32 tests pass
- Update PROGRESS.md: Phase 1 complete

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 03:44:44 -04:00

243 lines
7 KiB
Go

// Command acb-mapgen generates symmetric maps for AI Code Battle.
package main
import (
"encoding/json"
"flag"
"fmt"
"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 * 3.14159 / float64(numPlayers)
radius := 0.35 // 35% from center
r := centerRow + int(float64(centerRow)*radius*cos(angle))
c := centerCol + int(float64(centerCol)*radius*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 * 3.14159 / float64(numPlayers)
radius := 0.2 + rng.Float64()*0.5 // 20-70% from center
r := centerRow + int(float64(centerRow)*radius*cos(angle))
c := centerCol + int(float64(centerCol)*radius*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*3.14159/float64(numPlayers)
rr := centerRow + int(float64(centerRow)*radius*cos(rotAngle))
rc := centerCol + int(float64(centerCol)*radius*sin(rotAngle))
m.EnergyNodes = append(m.EnergyNodes, wrap(rr, rc))
}
break
}
}
}
// Generate walls with rotational symmetry
totalTiles := rows * cols
targetWalls := int(float64(totalTiles) * wallDensity)
wallsPerSector := targetWalls / numPlayers
for i := 0; i < wallsPerSector; i++ {
for attempt := 0; attempt < 100; attempt++ {
angle := rng.Float64() * 2.0 * 3.14159 / float64(numPlayers)
radius := 0.1 + rng.Float64()*0.7 // 10-80% from center
r := centerRow + int(float64(centerRow)*radius*cos(angle))
c := centerCol + int(float64(centerCol)*radius*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*3.14159/float64(numPlayers)
rr := centerRow + int(float64(centerRow)*radius*cos(rotAngle))
rc := centerCol + int(float64(centerCol)*radius*sin(rotAngle))
m.Walls = append(m.Walls, wrap(rr, rc))
}
break
}
}
}
return m
}
// Simple trig functions without importing math
func cos(x float64) float64 {
// Normalize to [0, 2π)
for x < 0 {
x += 2.0 * 3.14159
}
for x >= 2.0*3.14159 {
x -= 2.0 * 3.14159
}
// Taylor series approximation
return 1 - x*x/2 + x*x*x*x/24 - x*x*x*x*x*x/720
}
func sin(x float64) float64 {
// Normalize to [0, 2π)
for x < 0 {
x += 2.0 * 3.14159
}
for x >= 2.0*3.14159 {
x -= 2.0 * 3.14159
}
// Taylor series approximation
return x - x*x*x/6 + x*x*x*x*x/120
}