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:
parent
259b9de86a
commit
0f7d55c5d4
5 changed files with 432 additions and 83 deletions
26
PROGRESS.md
26
PROGRESS.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
191
cmd/acb-mapgen/mapgen_test.go
Normal file
191
cmd/acb-mapgen/mapgen_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue