- 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>
243 lines
7 KiB
Go
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
|
|
}
|