diff --git a/cmd/acb-mapgen/main.go b/cmd/acb-mapgen/main.go index 2ac6333..12756ca 100644 --- a/cmd/acb-mapgen/main.go +++ b/cmd/acb-mapgen/main.go @@ -174,7 +174,11 @@ func generateMap(numPlayers, rows, cols int, wallDensity float64, numEnergyNodes }) } - // Generate energy nodes with rotational symmetry + // 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) @@ -186,7 +190,17 @@ 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 * math.Pi / float64(numPlayers) - radius := 0.2 + rng.Float64()*0.5 // 20-70% from center + // 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.05–0.20: contested central zone + case i < nodesPerSector*7/10: + radius = 0.20 + rng.Float64()*0.20 // 0.20–0.40: mid-zone + default: + radius = 0.40 + rng.Float64()*0.20 // 0.40–0.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) diff --git a/cmd/acb-mapgen/mapgen_test.go b/cmd/acb-mapgen/mapgen_test.go index 476f09b..8133085 100644 --- a/cmd/acb-mapgen/mapgen_test.go +++ b/cmd/acb-mapgen/mapgen_test.go @@ -1,6 +1,7 @@ package main import ( + "math" "math/rand" "testing" ) @@ -189,3 +190,36 @@ func TestGenerateMap_Deterministic(t *testing.T) { } } } + +func TestGenerateMap_CenterWeightedEnergy(t *testing.T) { + // Verify energy nodes are biased toward the map center. + // For 2-player with 20 energy nodes, expect at least 30% in central zone. + // The tiered distribution should place ~30% of nodes in the inner 20% radius. + 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.Fatal("expected energy nodes, got 0") + } + + centerRow, centerCol := m.Rows/2, m.Cols/2 + maxRadius := float64(centerRow) * 0.20 // 20% of center distance = central zone + centralCount := 0 + + for _, en := range m.EnergyNodes { + dr := float64(en.Row) - float64(centerRow) + dc := float64(en.Col) - float64(centerCol) + dist := math.Sqrt(dr*dr + dc*dc) + if dist <= maxRadius { + centralCount++ + } + } + + // Expect at least 20% in central zone (allowing some variance for randomness) + minCentral := int(float64(len(m.EnergyNodes)) * 0.20) + if centralCount < minCentral { + t.Errorf("expected at least %d energy nodes in central zone, got %d", minCentral, centralCount) + } +} diff --git a/engine/integration_test.go b/engine/integration_test.go index 6d2f646..b8666c6 100644 --- a/engine/integration_test.go +++ b/engine/integration_test.go @@ -2,6 +2,7 @@ package engine import ( "encoding/json" + "math" "net/http" "net/http/httptest" "testing" @@ -194,3 +195,64 @@ func createMockBotServer(t *testing.T, secret string, playerID int) *httptest.Se w.Write(body) })) } +// TestIntegration_CenterWeightedEnergy verifies that energy nodes are biased +// toward the map center to force contested energy collection. +func TestIntegration_CenterWeightedEnergy(t *testing.T) { + secret := "test-energy-secret" + server := createMockBotServer(t, secret, 0) + defer server.Close() + + auth := AuthConfig{BotID: "b_test", Secret: secret, MatchID: "m_energy_test"} + bot := NewHTTPBot(server.URL, auth, WithHTTPTimeout(5*time.Second)) + + config := DefaultConfig() + config.Rows = 60 + config.Cols = 60 + config.MaxTurns = 10 // Short match for fast test + + runner := NewMatchRunner(config, + WithRNG(rand.New(rand.NewSource(42))), + WithTimeout(5*time.Second), + ) + + runner.AddBot(bot, "TestBot") + runner.AddBot(bot, "TestBot2") + + result, replay, err := runner.Run() + if err != nil { + t.Fatalf("Match failed: %v", err) + } + + if result == nil { + t.Fatal("Match result is nil") + } + + if replay == nil { + t.Fatal("Replay is nil") + } + + // Count energy nodes in central zone (20% of map radius) + centerRow, centerCol := config.Rows/2, config.Cols/2 + maxRadius := float64(centerRow) * 0.20 // 20% of center distance = central zone + centralCount := 0 + + for _, en := range replay.Map.EnergyNodes { + dr := float64(en.Row) - float64(centerRow) + dc := float64(en.Col) - float64(centerCol) + dist := math.Sqrt(dr*dr + dc*dc) + if dist <= maxRadius { + centralCount++ + } + } + + // Expect at least 20% in central zone (allowing some variance for randomness) + minCentral := int(float64(len(replay.Map.EnergyNodes)) * 0.20) + if centralCount < minCentral { + t.Errorf("expected at least %d energy nodes in central zone, got %d (total nodes: %d)", + minCentral, centralCount, len(replay.Map.EnergyNodes)) + } + + t.Logf("Center-weighted energy: %d/%d nodes in central zone (%.1f%%)", + centralCount, len(replay.Map.EnergyNodes), + 100.0*float64(centralCount)/float64(len(replay.Map.EnergyNodes))) +} diff --git a/engine/match.go b/engine/match.go index 7630cda..75964b7 100644 --- a/engine/match.go +++ b/engine/match.go @@ -311,10 +311,24 @@ func (mr *MatchRunner) placeEnergyNodes(gs *GameState, numPlayers int) { } nodesPerSector := numNodes / numPlayers + // 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% outer (0.40-0.60): outer zone for i := 0; i < nodesPerSector; i++ { // Generate one position in the first sector angle := mr.rng.Float64() * 2.0 * math.Pi / float64(numPlayers) - radius := 0.3 + mr.rng.Float64()*0.4 // 30-70% of half-size + // Tiered radius: bias toward center to force contested energy collection. + // 30% central (forces both players to midfield), 40% mid, 30% outer. + var radius float64 + switch { + case i < nodesPerSector*3/10: + radius = 0.05 + mr.rng.Float64()*0.15 // 0.05–0.20: contested central zone + case i < nodesPerSector*7/10: + radius = 0.20 + mr.rng.Float64()*0.20 // 0.20–0.40: mid-zone + default: + radius = 0.40 + mr.rng.Float64()*0.20 // 0.40–0.60: outer zone + } // Mirror for all players for p := 0; p < numPlayers; p++ {