ai-code-battle/cmd/acb-mapgen/main.go
jedarden 166d3ee277 fix(mapgen): align core placement radius with spawn radius fixes
Plan: §3.7.1 Spawn mechanics, §3.8 Map Generation

The map generator was using outdated core placement radius (0.35) that
placed cores too far apart on 40x40 maps (~28 tiles between cores on
opposite sides). This exceeded the attack radius (6 tiles for 2-player,
3.5 tiles for 3+ player), meaning generated maps didn't force combat.

The match runner was already fixed in commit e8fda06 to use:
- 2-player: primaryRadius = 0.15 (6 tiles apart = attack radius)
- 3+ player: primaryRadius = 0.063 (~3.4 tiles, within attack radius)

This change aligns cmd/acb-mapgen with the same logic, ensuring all
generated maps place cores within attack range.

Also adds validation test TestGenerateMap_CoresWithinAttackRadius to
verify cores are placed within attack radius on standard 40x40 maps.

Closes: bf-2wn4

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 08:35:13 -04:00

355 lines
11 KiB
Go
Raw 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, but spawn must put bots within attack range.
// For 2 players: within attack radius (6 tiles) so idle bots fight immediately
// For 3+ players: within attack radius (3.5 tiles) for same reason
var radius float64
if numPlayers == 2 {
radius = 0.15 // 6 tiles apart = exactly attack radius (6)
} else {
radius = 0.063 // ~3.4 tiles apart on toroidal grid (within attack radius of 3.46)
}
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
}