Fix sin/cos math bug and add cellular automata map generation

Replace broken Taylor series sin/cos approximations with math.Sin/math.Cos
in both engine/match.go and cmd/acb-mapgen. The Taylor series produced
incorrect results for angles > π, causing wrong positions in 3+ player maps.

Upgrade map generator wall placement from random scatter to cellular
automata (B5/S4 rule, 4 iterations) with rotational symmetry enforcement
and connectivity validation. Add comprehensive mapgen tests and dominance
win condition tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-03-26 01:10:46 -04:00
parent 259b9de86a
commit 0f7d55c5d4
5 changed files with 432 additions and 83 deletions

View file

@ -7,6 +7,32 @@
**Last Updated: 2026-03-26**
### Recent Changes (2026-03-26)
- Fixed math bug: replaced broken Taylor series sin/cos approximations with
`math.Sin`/`math.Cos` in `engine/match.go` and `cmd/acb-mapgen/main.go`.
The Taylor series produced incorrect results for angles > π, causing
incorrect core/energy/wall placement in 3+ player maps.
- Replaced random wall scatter with cellular automata wall generation in
`cmd/acb-mapgen/main.go`:
- Seeds full grid at 40% density
- Runs 4 iterations of B5/S4 cellular automata smoothing
- Enforces rotational symmetry by mirroring sector 0
- Thins to target density
- Protected zones around cores (3-tile radius) and energy nodes
- Produces natural cave-like wall structures instead of scattered dots
- Added comprehensive map generation tests (`cmd/acb-mapgen/mapgen_test.go`):
- Connectivity validation across all player counts and 10 seeds each
- Core count and ownership verification
- Energy node/wall non-overlap
- Wall density bounds checking
- Disconnected map detection (BFS validation)
- Small grid generation
- Determinism (same seed = same map)
- Added dominance win condition tests (`engine/turn_test.go`):
- 100-turn consecutive dominance threshold verification
- Dominance counter reset when falling below 80%
- All tests pass (engine + worker + mapgen)
### Previous Changes (2026-03-26)
- Added Kubernetes manifests for GitOps deployment via ArgoCD (`deploy/k8s/`)
- Namespace, ArgoCD Application with auto-sync and self-heal
- Deployments: match worker (2 replicas), index builder, 6 strategy bots

View file

@ -5,6 +5,7 @@ import (
"encoding/json"
"flag"
"fmt"
"math"
"math/rand"
"os"
"time"
@ -144,10 +145,10 @@ func generateMap(numPlayers, rows, cols int, wallDensity float64, numEnergyNodes
// Generate cores with rotational symmetry
for p := 0; p < numPlayers; p++ {
angle := float64(p) * 2.0 * 3.14159 / float64(numPlayers)
angle := float64(p) * 2.0 * math.Pi / float64(numPlayers)
radius := 0.35 // 35% from center
r := centerRow + int(float64(centerRow)*radius*cos(angle))
c := centerCol + int(float64(centerCol)*radius*sin(angle))
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,
@ -165,19 +166,19 @@ func generateMap(numPlayers, rows, cols int, wallDensity float64, numEnergyNodes
for i := 0; i < nodesPerSector; i++ {
for attempt := 0; attempt < 100; attempt++ {
angle := rng.Float64() * 2.0 * 3.14159 / float64(numPlayers)
angle := rng.Float64() * 2.0 * math.Pi / 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))
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*3.14159/float64(numPlayers)
rr := centerRow + int(float64(centerRow)*radius*cos(rotAngle))
rc := centerCol + int(float64(centerCol)*radius*sin(rotAngle))
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
@ -185,59 +186,130 @@ func generateMap(numPlayers, rows, cols int, wallDensity float64, numEnergyNodes
}
}
// Generate walls with rotational symmetry
totalTiles := rows * cols
targetWalls := int(float64(totalTiles) * wallDensity)
wallsPerSector := targetWalls / numPlayers
// 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.
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
// 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
}
// 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
}

View file

@ -0,0 +1,191 @@
package main
import (
"math/rand"
"testing"
)
func TestGenerateMap_Connectivity(t *testing.T) {
// Test that generated maps always pass connectivity validation
for _, players := range []int{2, 3, 4, 6} {
for seed := int64(1); seed <= 10; seed++ {
rng := rand.New(rand.NewSource(seed))
m := EnsureConnectivity(players, 60, 60, 0.15, 20, rng, 100)
if m == nil {
t.Errorf("players=%d seed=%d: failed to generate connected map", players, seed)
continue
}
if !CheckConnectivity(m) {
t.Errorf("players=%d seed=%d: map not connected after generation", players, seed)
}
}
}
}
func TestGenerateMap_CoreCount(t *testing.T) {
for _, players := range []int{2, 3, 4, 6} {
rng := rand.New(rand.NewSource(42))
m := EnsureConnectivity(players, 60, 60, 0.15, 20, rng, 100)
if m == nil {
t.Fatalf("players=%d: failed to generate map", players)
}
if len(m.Cores) != players {
t.Errorf("players=%d: expected %d cores, got %d", players, players, len(m.Cores))
}
// Verify each player has a core
owners := make(map[int]bool)
for _, c := range m.Cores {
owners[c.Owner] = true
}
for p := 0; p < players; p++ {
if !owners[p] {
t.Errorf("players=%d: player %d has no core", players, p)
}
}
}
}
func TestGenerateMap_EnergyNodes(t *testing.T) {
rng := rand.New(rand.NewSource(42))
m := EnsureConnectivity(2, 60, 60, 0.15, 20, rng, 100)
if m == nil {
t.Fatal("failed to generate map")
}
if len(m.EnergyNodes) == 0 {
t.Error("expected energy nodes, got 0")
}
// Energy nodes should not overlap with walls
wallSet := make(map[Position]bool)
for _, w := range m.Walls {
wallSet[w] = true
}
for _, en := range m.EnergyNodes {
if wallSet[en] {
t.Errorf("energy node at %v overlaps with wall", en)
}
}
}
func TestGenerateMap_WallDensity(t *testing.T) {
rng := rand.New(rand.NewSource(42))
density := 0.15
m := EnsureConnectivity(2, 60, 60, density, 20, rng, 100)
if m == nil {
t.Fatal("failed to generate map")
}
totalTiles := m.Rows * m.Cols
actualDensity := float64(len(m.Walls)) / float64(totalTiles)
if actualDensity > density+0.01 {
t.Errorf("wall density %.2f exceeds target %.2f", actualDensity, density)
}
}
func TestGenerateMap_NoCoresOnWalls(t *testing.T) {
for _, players := range []int{2, 3, 4, 6} {
rng := rand.New(rand.NewSource(42))
m := EnsureConnectivity(players, 60, 60, 0.15, 20, rng, 100)
if m == nil {
t.Fatalf("players=%d: failed to generate map", players)
}
wallSet := make(map[Position]bool)
for _, w := range m.Walls {
wallSet[w] = true
}
for _, c := range m.Cores {
if wallSet[c.Position] {
t.Errorf("players=%d: core at %v overlaps with wall", players, c.Position)
}
}
}
}
func TestCheckConnectivity_FullyOpen(t *testing.T) {
m := &Map{
Rows: 10,
Cols: 10,
Walls: nil,
Cores: []Core{{Position: Position{0, 0}, Owner: 0}},
}
if !CheckConnectivity(m) {
t.Error("fully open map should be connected")
}
}
func TestCheckConnectivity_Disconnected(t *testing.T) {
// Create a wall that bisects the grid vertically
var walls []Position
for r := 0; r < 10; r++ {
walls = append(walls, Position{Row: r, Col: 5})
}
m := &Map{
Rows: 10,
Cols: 10,
Walls: walls,
Cores: []Core{{Position: Position{0, 0}, Owner: 0}},
}
// On a toroidal grid, a single column of walls doesn't disconnect
// because you can wrap around. So this should still be connected.
if !CheckConnectivity(m) {
t.Error("toroidal grid with one column of walls should still be connected")
}
}
func TestCheckConnectivity_DisconnectedBox(t *testing.T) {
// Create a sealed box in a non-toroidal way - surround a region
var walls []Position
// Create a 3x3 box of walls around position (5,5)
for r := 3; r <= 7; r++ {
for c := 3; c <= 7; c++ {
if r == 3 || r == 7 || c == 3 || c == 7 {
walls = append(walls, Position{Row: r, Col: c})
}
}
}
m := &Map{
Rows: 10,
Cols: 10,
Walls: walls,
Cores: []Core{{Position: Position{0, 0}, Owner: 0}},
}
// The interior of the box (4-6, 4-6) is disconnected from the rest
if CheckConnectivity(m) {
t.Error("map with sealed interior should be disconnected")
}
}
func TestGenerateMap_SmallGrid(t *testing.T) {
// Ensure map generation works on small grids
rng := rand.New(rand.NewSource(42))
m := EnsureConnectivity(2, 20, 20, 0.10, 8, rng, 100)
if m == nil {
t.Fatal("failed to generate connected map on small grid")
}
if !CheckConnectivity(m) {
t.Error("small grid map not connected")
}
}
func TestGenerateMap_Deterministic(t *testing.T) {
// Same seed should produce same map
rng1 := rand.New(rand.NewSource(123))
m1 := generateMap(2, 60, 60, 0.15, 20, rng1)
rng2 := rand.New(rand.NewSource(123))
m2 := generateMap(2, 60, 60, 0.15, 20, rng2)
if len(m1.Walls) != len(m2.Walls) {
t.Fatalf("determinism: wall count differs: %d vs %d", len(m1.Walls), len(m2.Walls))
}
if len(m1.Cores) != len(m2.Cores) {
t.Fatal("determinism: core count differs")
}
if len(m1.EnergyNodes) != len(m2.EnergyNodes) {
t.Fatal("determinism: energy node count differs")
}
for i, w := range m1.Walls {
if w != m2.Walls[i] {
t.Errorf("determinism: wall %d differs: %v vs %v", i, w, m2.Walls[i])
break
}
}
}

View file

@ -3,6 +3,7 @@ package engine
import (
"fmt"
"log"
"math"
"math/rand"
"sync"
"time"
@ -230,9 +231,9 @@ func (mr *MatchRunner) generateMap(gs *GameState, numPlayers int) {
// 120° rotational symmetry (equilateral triangle)
// Simplified: place at roughly equal angles
for i := 0; i < 3; i++ {
angle := float64(i) * 2.0 * 3.14159 / 3.0
row := centerRow + int(float64(centerRow/2)*0.8*(1.0+0.5*cos(angle)))
col := centerCol + int(float64(centerCol/2)*0.8*(1.0+0.5*sin(angle)))
angle := float64(i) * 2.0 * math.Pi / 3.0
row := centerRow + int(float64(centerRow/2)*0.8*(1.0+0.5*math.Cos(angle)))
col := centerCol + int(float64(centerCol/2)*0.8*(1.0+0.5*math.Sin(angle)))
pos := Position{Row: row, Col: col}
gs.AddCore(i, pos)
gs.SpawnBot(i, pos)
@ -255,9 +256,9 @@ func (mr *MatchRunner) generateMap(gs *GameState, numPlayers int) {
default:
// Fallback: place cores in a circle
for i := 0; i < numPlayers; i++ {
angle := float64(i) * 2.0 * 3.14159 / float64(numPlayers)
row := centerRow + int(float64(centerRow/2)*0.7*cos(angle))
col := centerCol + int(float64(centerCol/2)*0.7*sin(angle))
angle := float64(i) * 2.0 * math.Pi / float64(numPlayers)
row := centerRow + int(float64(centerRow/2)*0.7*math.Cos(angle))
col := centerCol + int(float64(centerCol/2)*0.7*math.Sin(angle))
pos := Position{Row: row, Col: col}
gs.AddCore(i, pos)
gs.SpawnBot(i, pos)
@ -282,14 +283,14 @@ func (mr *MatchRunner) placeEnergyNodes(gs *GameState, numPlayers int) {
for i := 0; i < nodesPerSector; i++ {
// Generate one position in the first sector
angle := mr.rng.Float64() * 2.0 * 3.14159 / float64(numPlayers)
angle := mr.rng.Float64() * 2.0 * math.Pi / float64(numPlayers)
radius := 0.3 + mr.rng.Float64()*0.4 // 30-70% of half-size
// Mirror for all players
for p := 0; p < numPlayers; p++ {
rotAngle := angle + float64(p)*2.0*3.14159/float64(numPlayers)
r := centerRow + int(float64(centerRow)*radius*cos(rotAngle))
c := centerCol + int(float64(centerCol)*radius*sin(rotAngle))
rotAngle := angle + float64(p)*2.0*math.Pi/float64(numPlayers)
r := centerRow + int(float64(centerRow)*radius*math.Cos(rotAngle))
c := centerCol + int(float64(centerCol)*radius*math.Sin(rotAngle))
gs.AddEnergyNode(Position{Row: r, Col: c})
}
}
@ -307,19 +308,19 @@ func (mr *MatchRunner) placeWalls(gs *GameState, numPlayers int) {
for i := 0; i < wallsPerSector; i++ {
// Generate one position in the first sector
angle := mr.rng.Float64() * 2.0 * 3.14159 / float64(numPlayers)
angle := mr.rng.Float64() * 2.0 * math.Pi / float64(numPlayers)
radius := 0.1 + mr.rng.Float64()*0.8 // 10-90% of half-size
row := centerRow + int(float64(centerRow)*radius*cos(angle))
col := centerCol + int(float64(centerCol)*radius*sin(angle))
row := centerRow + int(float64(centerRow)*radius*math.Cos(angle))
col := centerCol + int(float64(centerCol)*radius*math.Sin(angle))
// Check it's not on a core or energy node
pos := Position{Row: row, Col: col}
if mr.isValidWallPosition(gs, pos) {
// Mirror for all players
for p := 0; p < numPlayers; p++ {
rotAngle := angle + float64(p)*2.0*3.14159/float64(numPlayers)
r := centerRow + int(float64(centerRow)*radius*cos(rotAngle))
c := centerCol + int(float64(centerCol)*radius*sin(rotAngle))
rotAngle := angle + float64(p)*2.0*math.Pi/float64(numPlayers)
r := centerRow + int(float64(centerRow)*radius*math.Cos(rotAngle))
c := centerCol + int(float64(centerCol)*radius*math.Sin(rotAngle))
mirrorPos := Position{Row: r, Col: c}
if mr.isValidWallPosition(gs, mirrorPos) {
gs.Grid.SetPos(mirrorPos, TileWall)
@ -346,13 +347,3 @@ func (mr *MatchRunner) isValidWallPosition(gs *GameState, pos Position) bool {
return true
}
// cos and sin helpers (avoid importing math for simple cases)
func cos(x float64) float64 {
// Simple approximation using Taylor series
return 1 - x*x/2 + x*x*x*x/24 - x*x*x*x*x*x/720
}
func sin(x float64) float64 {
// Simple approximation using Taylor series
return x - x*x*x/6 + x*x*x*x*x/120
}

View file

@ -391,6 +391,75 @@ func TestCheckWinConditionsDraw(t *testing.T) {
}
}
func TestCheckWinConditionsDominance(t *testing.T) {
gs := newTestGameState()
p0 := gs.AddPlayer()
p1 := gs.AddPlayer()
// Player 0 has 9 bots, player 1 has 1 bot = 90% dominance
for i := 0; i < 9; i++ {
gs.SpawnBot(p0.ID, Position{Row: i, Col: 0})
}
gs.SpawnBot(p1.ID, Position{Row: 15, Col: 15})
// Dominance requires 100 consecutive turns at >= 80%
// First 99 turns should not trigger
for i := 0; i < 99; i++ {
result := gs.checkWinConditions()
if result != nil && result.Reason == "dominance" {
t.Fatalf("dominance should not trigger at turn %d (only %d consecutive)", i, i+1)
}
}
// 100th check should trigger dominance
result := gs.checkWinConditions()
if result == nil {
t.Fatal("expected dominance win after 100 consecutive turns")
}
if result.Winner != p0.ID {
t.Errorf("winner = %d, want %d", result.Winner, p0.ID)
}
if result.Reason != "dominance" {
t.Errorf("reason = %s, want dominance", result.Reason)
}
}
func TestCheckWinConditionsDominanceReset(t *testing.T) {
gs := newTestGameState()
p0 := gs.AddPlayer()
p1 := gs.AddPlayer()
// Player 0 has 9 bots, player 1 has 1 = 90% dominance
bots0 := make([]*Bot, 9)
for i := 0; i < 9; i++ {
bots0[i] = gs.SpawnBot(p0.ID, Position{Row: i, Col: 0})
}
gs.SpawnBot(p1.ID, Position{Row: 15, Col: 15})
// Run 50 turns of dominance
for i := 0; i < 50; i++ {
result := gs.checkWinConditions()
if result != nil && result.Reason == "dominance" {
t.Fatalf("dominance should not trigger at %d turns", i+1)
}
}
// Break dominance by killing some p0 bots
for i := 0; i < 6; i++ {
gs.KillBot(bots0[i], "test")
}
// Now p0 has 3 bots, p1 has 1 = 75% (< 80%)
result := gs.checkWinConditions()
// Should not trigger dominance and counter should reset
if result != nil && result.Reason == "dominance" {
t.Error("dominance should not trigger when below 80%")
}
if gs.Dominance[p0.ID] != 0 {
t.Errorf("dominance counter should reset to 0, got %d", gs.Dominance[p0.ID])
}
}
func TestCheckWinConditionsTurns(t *testing.T) {
gs := newTestGameState()
p0 := gs.AddPlayer()