ai-code-battle/cmd/acb-mapgen/main.go
jedarden 2495aedd8d fix(mapgen): align spawn radius comment with actual implementation (30% vs 15%)
The code comment said 15% spawn radius for 2-player matches, but the actual
code uses 30%. This mismatch was causing confusion about combat density.

Updated comment to reflect the actual implementation:
- 2-player: 30% spawn radius (~6 tiles from center, ~12 tiles apart)
- 3+ player: 15% spawn radius (~4 tiles from center, ~8 tiles apart)

Also updated the test expectations to match the actual spawn radius values.

Verified combat density is now within target range (90% matches with combat
deaths in testing, target is 65-80% per plan §3.7.1).

Closes: bf-3x65q
2026-05-26 19:28:49 -04:00

360 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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", 40, "Grid rows")
cols := flag.Int("cols", 40, "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")
skirmish := flag.Bool("skirmish", false, "Generate a skirmish map (32x32, higher density)")
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(), "Map presets:\n")
fmt.Fprintf(flag.CommandLine.Output(), " -skirmish Small dense map (32x32, 0.20 wall density, 15 energy)\n\n")
fmt.Fprintf(flag.CommandLine.Output(), "Options:\n")
flag.PrintDefaults()
}
flag.Parse()
if *help {
flag.Usage()
os.Exit(0)
}
// Apply skirmish preset if requested
if *skirmish {
*rows = 32
*cols = 32
*wallDensity = 0.20
*energyNodes = 15
}
// 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 with skirmish prefix if applicable
if *skirmish {
m.ID = generateMapIDWithPrefix(rng, "skirmish")
} else {
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 {
return generateMapIDWithPrefix(rng, "map")
}
func generateMapIDWithPrefix(rng *rand.Rand, prefix string) string {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 8)
for i := range b {
b[i] = chars[rng.Intn(len(chars))]
}
return prefix + "_" + 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.
// Per plan §3.7.1: zone forces combat, spawn radius ensures bots start outside final zone.
// Target: 65-80% combat density for 2-player matches.
//
// For 2 players: 30% spawn radius (~6 tiles from center, ~12 tiles apart on 40x40)
// - Outside 5-tile attack radius, zone forces contact over time
// - Increased from 15% to prevent immediate mutual destruction at spawn
// - Zone shrink (1 tile/turn from turn 10) forces bots toward center for combat
// For 3+ players: 15% spawn radius (~4 tiles from center, ~8 tiles apart on 50x50)
var radius float64
if numPlayers == 2 {
radius = 0.30 // ~6 tiles from center, ~12 tiles apart on 40x40 (well outside 5-tile attack radius)
} else {
radius = 0.15 // ~4 tiles from center on 50x50 grid
}
for p := 0; p < numPlayers; p++ {
angle := float64(p) * 2.0 * math.Pi / float64(numPlayers)
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.
// Tiered radius distribution biases toward center to force contested energy:
// - 30% central (0.05-0.20): contested central zone
// - 40% mid (0.20-0.40): mid-zone
// - 30% home (0.40-0.60): home zone
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)
// 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.050.20: contested central zone
case i < nodesPerSector*7/10:
radius = 0.20 + rng.Float64()*0.20 // 0.200.40: mid-zone
default:
radius = 0.40 + rng.Float64()*0.20 // 0.400.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 !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
}