From 0f7d55c5d4fec053a915e8ddd2f13299ad139ab9 Mon Sep 17 00:00:00 2001 From: jedarden Date: Thu, 26 Mar 2026 01:10:46 -0400 Subject: [PATCH] Fix sin/cos math bug and add cellular automata map generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- PROGRESS.md | 26 +++++ cmd/acb-mapgen/main.go | 186 +++++++++++++++++++++++---------- cmd/acb-mapgen/mapgen_test.go | 191 ++++++++++++++++++++++++++++++++++ engine/match.go | 43 +++----- engine/turn_test.go | 69 ++++++++++++ 5 files changed, 432 insertions(+), 83 deletions(-) create mode 100644 cmd/acb-mapgen/mapgen_test.go diff --git a/PROGRESS.md b/PROGRESS.md index 17ac1c9..f13db73 100644 --- a/PROGRESS.md +++ b/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 diff --git a/cmd/acb-mapgen/main.go b/cmd/acb-mapgen/main.go index 1e28698..4980479 100644 --- a/cmd/acb-mapgen/main.go +++ b/cmd/acb-mapgen/main.go @@ -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 -} diff --git a/cmd/acb-mapgen/mapgen_test.go b/cmd/acb-mapgen/mapgen_test.go new file mode 100644 index 0000000..476f09b --- /dev/null +++ b/cmd/acb-mapgen/mapgen_test.go @@ -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 + } + } +} diff --git a/engine/match.go b/engine/match.go index bfa67fa..86ddc7c 100644 --- a/engine/match.go +++ b/engine/match.go @@ -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 -} diff --git a/engine/turn_test.go b/engine/turn_test.go index 59fc635..402f768 100644 --- a/engine/turn_test.go +++ b/engine/turn_test.go @@ -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()