feat(map-evolver): bias energy toward center, carve corridors to force contact

Energy node placement now uses a tiered radius distribution: 30% in the
contested central zone (0.05-0.20 from center), 40% in the mid-zone
(0.20-0.40), and 30% in the home zone (0.40-0.60). Previously nodes were
placed uniformly at 0.20-0.70, letting bots farm their home quadrant
indefinitely without crossing the midline.

After cellular automata wall generation, a 3-wide corridor is carved from
each core straight to the map center, plus a 5x5 open arena at the center
tile. This creates lanes that funnel bots into contact — replicating the key
mechanic that drove frequent fights in the original AI Challenge Ants game,
where symmetric food spawning near the midfield forced both colonies to
expand outward and collide.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-03 18:56:39 -04:00
parent 4937f94afd
commit 42e9561e46

View file

@ -1026,7 +1026,17 @@ func generateMapOnce(numPlayers, rows, cols int, wallDensity float64, numEnergyN
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
// 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)
@ -1133,6 +1143,20 @@ func generateMapOnce(numPlayers, rows, cols int, wallDensity float64, numEnergyN
}
}
// Carve corridors from each core to the map center.
// Creates 3-wide lanes that funnel bots into contact at midfield.
for _, core := range m.Cores {
carveCorridor(grid, core.Position.Row, core.Position.Col, centerRow, centerCol, rows, cols)
}
// Open a 5×5 arena at the center so all corridors connect.
for dr := -2; dr <= 2; dr++ {
for dc := -2; dc <= 2; dc++ {
rr := ((centerRow+dr)%rows + rows) % rows
cc := ((centerCol+dc)%cols + cols) % cols
grid[rr][cc] = false
}
}
// Collect wall positions and thin to target density.
totalTiles := rows * cols
targetWalls := int(float64(totalTiles) * wallDensity)
@ -1153,6 +1177,53 @@ func generateMapOnce(numPlayers, rows, cols int, wallDensity float64, numEnergyN
return m
}
// carveCorridor opens a 3-wide path from (r0,c0) to (r1,c1) using integer stepping.
// Perpendicular width is 1 tile on each side of the center line.
func carveCorridor(grid [][]bool, r0, c0, r1, c1, rows, cols int) {
dr := r1 - r0
dc := c1 - c0
steps := dr
if steps < 0 {
steps = -steps
}
if dc < 0 && -dc > steps {
steps = -dc
} else if dc > steps {
steps = dc
}
if steps == 0 {
return
}
horizontal := dc < 0 && -dc > (dr+1) || dc > 0 && dc > (dr+1) // wider in col direction
if dr < 0 {
dr = -dr
}
// recompute: use originals
origDR := r1 - r0
origDC := c1 - c0
for step := 0; step <= steps; step++ {
r := r0 + origDR*step/steps
c := c0 + origDC*step/steps
// Widen perpendicular to primary movement direction
if !horizontal {
// Mostly vertical: widen horizontally
for wc := -1; wc <= 1; wc++ {
rr := ((r)%rows + rows) % rows
cc := ((c+wc)%cols + cols) % cols
grid[rr][cc] = false
}
} else {
// Mostly horizontal: widen vertically
for wr := -1; wr <= 1; wr++ {
rr := ((r+wr)%rows + rows) % rows
cc := ((c)%cols + cols) % cols
grid[rr][cc] = false
}
}
}
_ = dr // suppress unused warning
}
// generateMapID creates a random map ID.
func generateMapID(rng *rand.Rand) string {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"