From 890785c5c4a14d7ffc0be7fc728694ecd9cc5cd7 Mon Sep 17 00:00:00 2001 From: jedarden Date: Tue, 24 Mar 2026 03:44:44 -0400 Subject: [PATCH] Complete Phase 1: add connectivity validation and determinism tests - Add connectivity.go: BFS-based map connectivity validation with retry - Update mapgen to use connectivity checking by default - Add determinism_test.go: property-based tests for reproducibility - Same seed produces identical replays - Turn execution is deterministic - Grid operations are deterministic - Combat resolution is deterministic - Full 500-turn match validation - All 32 tests pass - Update PROGRESS.md: Phase 1 complete Co-Authored-By: Claude Opus 4.6 --- PROGRESS.md | 22 +- cmd/acb-mapgen/connectivity.go | 100 +++++++++ cmd/acb-mapgen/main.go | 12 +- engine/determinism_test.go | 383 +++++++++++++++++++++++++++++++++ 4 files changed, 506 insertions(+), 11 deletions(-) create mode 100644 cmd/acb-mapgen/connectivity.go create mode 100644 engine/determinism_test.go diff --git a/PROGRESS.md b/PROGRESS.md index 80a4c8d..2776b70 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ ## Current Phase: Phase 1 - Core Engine -**Status: In Progress (~80% complete)** +**Status: ✅ COMPLETE** ### Completed @@ -51,12 +51,16 @@ - Energy collection - Spawning - Win conditions - -### Remaining for Phase 1 - -- [ ] Improve map generator with connectivity validation -- [ ] Add property-based tests for determinism -- [ ] Run full 500-turn match validation +- [x] Map generator connectivity validation (`cmd/acb-mapgen/connectivity.go`) + - BFS-based connectivity check + - Retry mechanism for connected map generation +- [x] Determinism tests (`engine/determinism_test.go`) + - Same seed produces identical replays + - Turn execution is deterministic + - Grid operations are deterministic + - Combat resolution is deterministic + - Replay serialization round-trip + - Full 500-turn match validation ### Exit Criteria Progress @@ -64,11 +68,11 @@ |-----------|--------| | Can run a complete 500-turn match locally | ✅ Works | | Produce a valid replay file | ✅ Works | -| Comprehensive unit tests | ✅ 26 tests passing | +| Comprehensive unit tests | ✅ 32 tests passing | ## Next Phase: Phase 2 - HTTP Protocol & Strategy Bots -Not started. +**Status: Ready to start** ## File Structure diff --git a/cmd/acb-mapgen/connectivity.go b/cmd/acb-mapgen/connectivity.go new file mode 100644 index 0000000..219c658 --- /dev/null +++ b/cmd/acb-mapgen/connectivity.go @@ -0,0 +1,100 @@ +// Command acb-mapgen generates symmetric maps for AI Code Battle. +package main + +import ( + "container/list" + "math/rand" +) + +// PositionSet is a set of positions for fast lookup. +type PositionSet map[Position]bool + +// CheckConnectivity verifies that all passable tiles in the map are reachable +// from each player's core. This is critical for ensuring fair gameplay. +// +// For toroidal grids, we use BFS from a starting point and verify all +// passable tiles are visited. +func CheckConnectivity(m *Map) bool { + if len(m.Cores) == 0 { + return true // No cores, nothing to validate + } + + // Build a set of passable positions + passable, totalPassable := m.passablePositions() + if totalPassable == 0 { + return false // No passable tiles at all + } + + // BFS from first core position + start := m.Cores[0].Position + if !passable[start] { + return false // Core itself is not passable + } + + visited := make(PositionSet) + queue := list.New() + queue.PushBack(start) + visited[start] = true + count := 1 + + // Direction deltas for 4-connected neighbors (cardinal directions) + dirs := []Position{{-1, 0}, {1, 0}, {0, -1}, {0, 1}} + + for queue.Len() > 0 { + front := queue.Front() + queue.Remove(front) + curr := front.Value.(Position) + + for _, d := range dirs { + // Toroidal wrapping + nr := ((curr.Row + d.Row) % m.Rows + m.Rows) % m.Rows + nc := ((curr.Col + d.Col) % m.Cols + m.Cols) % m.Cols + np := Position{Row: nr, Col: nc} + + if passable[np] && !visited[np] { + visited[np] = true + queue.PushBack(np) + count++ + } + } + } + + // All passable tiles must be visited + return count == totalPassable +} + +// passablePositions returns all positions that are not walls and the count. +func (m *Map) passablePositions() (PositionSet, int) { + result := make(PositionSet) + + // Build wall set for fast lookup + wallSet := make(map[Position]bool) + for _, w := range m.Walls { + wallSet[w] = true + } + + for r := 0; r < m.Rows; r++ { + for c := 0; c < m.Cols; c++ { + p := Position{Row: r, Col: c} + if !wallSet[p] { + result[p] = true + } + } + } + return result, len(result) +} + +// EnsureConnectivity generates walls while maintaining full connectivity. +// It uses a retry mechanism: if walls disconnect the map, regenerate and try again. +func EnsureConnectivity(numPlayers, rows, cols int, wallDensity float64, numEnergyNodes int, rng *rand.Rand, maxAttempts int) *Map { + for attempt := 0; attempt < maxAttempts; attempt++ { + m := generateMap(numPlayers, rows, cols, wallDensity, numEnergyNodes, rng) + + if CheckConnectivity(m) { + return m + } + } + + // If all attempts failed, return nil + return nil +} diff --git a/cmd/acb-mapgen/main.go b/cmd/acb-mapgen/main.go index 2b72e6e..1e28698 100644 --- a/cmd/acb-mapgen/main.go +++ b/cmd/acb-mapgen/main.go @@ -44,11 +44,14 @@ func main() { energyNodes := flag.Int("energy-nodes", 20, "Energy nodes") seed := flag.Int64("seed", time.Now().UnixNano(), "Random seed") output := flag.String("output", "", "Output file (default: stdout)") + maxAttempts := flag.Int("max-attempts", 100, "Max attempts to generate a connected map") help := flag.Bool("help", false, "Show help") flag.Usage = func() { fmt.Fprintf(flag.CommandLine.Output(), "Usage: acb-mapgen [options]\n\n") fmt.Fprintf(flag.CommandLine.Output(), "Generate a symmetric map for AI Code Battle.\n\n") + fmt.Fprintf(flag.CommandLine.Output(), "The generator ensures all passable tiles are reachable from\n") + fmt.Fprintf(flag.CommandLine.Output(), "any core (full connectivity guarantee).\n\n") fmt.Fprintf(flag.CommandLine.Output(), "Symmetry types:\n") fmt.Fprintf(flag.CommandLine.Output(), " 2 players: 180° rotational\n") fmt.Fprintf(flag.CommandLine.Output(), " 3 players: 120° rotational\n") @@ -78,9 +81,14 @@ func main() { os.Exit(1) } - // Generate map + // Generate map with connectivity validation rng := rand.New(rand.NewSource(*seed)) - m := generateMap(*players, *rows, *cols, *wallDensity, *energyNodes, rng) + m := EnsureConnectivity(*players, *rows, *cols, *wallDensity, *energyNodes, rng, *maxAttempts) + if m == nil { + fmt.Fprintf(os.Stderr, "Error: failed to generate a connected map after %d attempts\n", *maxAttempts) + fmt.Fprintf(os.Stderr, "Try reducing wall density or increasing max-attempts\n") + os.Exit(1) + } // Generate map ID m.ID = generateMapID(rng) diff --git a/engine/determinism_test.go b/engine/determinism_test.go new file mode 100644 index 0000000..9b78c03 --- /dev/null +++ b/engine/determinism_test.go @@ -0,0 +1,383 @@ +package engine + +import ( + "encoding/json" + "math/rand" + "testing" + "time" +) + +// TestDeterminism_SameSeedSameReplay verifies that running the same match +// with the same seed produces identical replay output (excluding timestamps). +func TestDeterminism_SameSeedSameReplay(t *testing.T) { + seed := int64(12345) + config := DefaultConfig() + config.Rows = 20 + config.Cols = 20 + config.MaxTurns = 50 // Shorter for testing + + runMatch := func() *Replay { + rng := rand.New(rand.NewSource(seed)) + mr := NewMatchRunner(config, + WithRNG(rng), + WithTimeout(1*time.Second), + ) + + // Use deterministic bots (IdleBot always returns same moves) + mr.AddBot(NewIdleBot(), "Idle1") + mr.AddBot(NewIdleBot(), "Idle2") + + _, replay, err := mr.Run() + if err != nil { + t.Fatalf("Match failed: %v", err) + } + return replay + } + + // Run match twice + replay1 := runMatch() + replay2 := runMatch() + + // Compare match ID (deterministic from seed) + if replay1.MatchID != replay2.MatchID { + t.Errorf("MatchID differs: %s vs %s", replay1.MatchID, replay2.MatchID) + } + + // Compare map (should be identical) + if len(replay1.Map.Walls) != len(replay2.Map.Walls) { + t.Errorf("Wall count differs: %d vs %d", len(replay1.Map.Walls), len(replay2.Map.Walls)) + } + if len(replay1.Map.Cores) != len(replay2.Map.Cores) { + t.Errorf("Core count differs: %d vs %d", len(replay1.Map.Cores), len(replay2.Map.Cores)) + } + + // Compare turns + if len(replay1.Turns) != len(replay2.Turns) { + t.Fatalf("Turn count differs: %d vs %d", len(replay1.Turns), len(replay2.Turns)) + } + + for i := range replay1.Turns { + t1 := replay1.Turns[i] + t2 := replay2.Turns[i] + + if t1.Turn != t2.Turn { + t.Errorf("Turn %d number differs: %d vs %d", i, t1.Turn, t2.Turn) + } + + if len(t1.Bots) != len(t2.Bots) { + t.Errorf("Turn %d bot count differs: %d vs %d", i, len(t1.Bots), len(t2.Bots)) + continue + } + + for j := range t1.Bots { + b1 := t1.Bots[j] + b2 := t2.Bots[j] + + if b1.Position != b2.Position { + t.Errorf("Turn %d bot %d position differs: %v vs %v", i, j, b1.Position, b2.Position) + } + if b1.Alive != b2.Alive { + t.Errorf("Turn %d bot %d alive differs: %v vs %v", i, j, b1.Alive, b2.Alive) + } + if b1.Owner != b2.Owner { + t.Errorf("Turn %d bot %d owner differs: %d vs %d", i, j, b1.Owner, b2.Owner) + } + } + + // Compare scores + for j := range t1.Scores { + if j >= len(t2.Scores) { + break + } + if t1.Scores[j] != t2.Scores[j] { + t.Errorf("Turn %d player %d score differs: %d vs %d", i, j, t1.Scores[j], t2.Scores[j]) + } + } + } + + // Compare result + if replay1.Result != nil && replay2.Result != nil { + if replay1.Result.Winner != replay2.Result.Winner { + t.Errorf("Winner differs: %d vs %d", replay1.Result.Winner, replay2.Result.Winner) + } + if replay1.Result.Reason != replay2.Result.Reason { + t.Errorf("Reason differs: %s vs %s", replay1.Result.Reason, replay2.Result.Reason) + } + if replay1.Result.Turns != replay2.Result.Turns { + t.Errorf("Result turns differs: %d vs %d", replay1.Result.Turns, replay2.Result.Turns) + } + } +} + +// TestDeterminism_TurnExecutionIsDeterministic verifies that executing +// the same turn with the same moves produces identical results. +func TestDeterminism_TurnExecutionIsDeterministic(t *testing.T) { + runTurn := func() *GameState { + config := DefaultConfig() + config.Rows = 10 + config.Cols = 10 + + rng := rand.New(rand.NewSource(42)) + gs := NewGameState(config, rng) + + p0 := gs.AddPlayer() + p1 := gs.AddPlayer() + + // Set up identical initial state + gs.AddCore(p0.ID, Position{5, 5}) + gs.AddCore(p1.ID, Position{4, 4}) + + bot0 := gs.SpawnBot(p0.ID, Position{5, 5}) + bot1 := gs.SpawnBot(p1.ID, Position{4, 4}) + + // Add energy node + en := gs.AddEnergyNode(Position{3, 3}) + en.HasEnergy = true + en.Tick = 0 + + // Submit same moves + gs.SubmitMove(bot0.Position, DirN) + gs.SubmitMove(bot1.Position, DirE) + + // Execute turn + gs.ExecuteTurn() + + return gs + } + + // Run twice (seeds shouldn't matter for deterministic turn execution) + gs1 := runTurn() + gs2 := runTurn() + + // Compare states + if gs1.Turn != gs2.Turn { + t.Errorf("Turn differs: %d vs %d", gs1.Turn, gs2.Turn) + } + + if len(gs1.Bots) != len(gs2.Bots) { + t.Errorf("Bot count differs: %d vs %d", len(gs1.Bots), len(gs2.Bots)) + } + + for i := range gs1.Bots { + if i >= len(gs2.Bots) { + break + } + b1 := gs1.Bots[i] + b2 := gs2.Bots[i] + + if b1.Position != b2.Position { + t.Errorf("Bot %d position differs: %v vs %v", i, b1.Position, b2.Position) + } + if b1.Alive != b2.Alive { + t.Errorf("Bot %d alive differs: %v vs %v", i, b1.Alive, b2.Alive) + } + } + + // Compare scores + for i := range gs1.Players { + if i >= len(gs2.Players) { + break + } + if gs1.Players[i].Score != gs2.Players[i].Score { + t.Errorf("Player %d score differs: %d vs %d", + i, gs1.Players[i].Score, gs2.Players[i].Score) + } + if gs1.Players[i].Energy != gs2.Players[i].Energy { + t.Errorf("Player %d energy differs: %d vs %d", + i, gs1.Players[i].Energy, gs2.Players[i].Energy) + } + } +} + +// TestDeterminism_GridOperationsAreDeterministic verifies grid operations +// produce consistent results. +func TestDeterminism_GridOperationsAreDeterministic(t *testing.T) { + g := NewGrid(60, 60) + + // Test wrapping + for r := -100; r <= 100; r += 10 { + for c := -100; c <= 100; c += 10 { + p1 := g.Wrap(r, c) + p2 := g.Wrap(r, c) + if p1 != p2 { + t.Errorf("Wrap(%d,%d) not deterministic: %v vs %v", r, c, p1, p2) + } + } + } + + // Test distance + positions := []Position{ + {0, 0}, {30, 30}, {59, 59}, {10, 20}, {50, 10}, + } + for _, a := range positions { + for _, b := range positions { + d1 := g.Distance2(a, b) + d2 := g.Distance2(a, b) + if d1 != d2 { + t.Errorf("Distance2(%v, %v) not deterministic: %d vs %d", a, b, d1, d2) + } + // Distance should be symmetric + d3 := g.Distance2(b, a) + if d1 != d3 { + t.Errorf("Distance2 not symmetric: %v->%v = %d, %v->%v = %d", + a, b, d1, b, a, d3) + } + } + } + + // Test visibility + vis1 := g.VisibleFrom(positions, 49) + vis2 := g.VisibleFrom(positions, 49) + for p := range vis1 { + if !vis2[p] { + t.Errorf("VisibleFrom not deterministic: %v in vis1 but not vis2", p) + } + } + for p := range vis2 { + if !vis1[p] { + t.Errorf("VisibleFrom not deterministic: %v in vis2 but not vis1", p) + } + } +} + +// TestDeterminism_CombatResolutionIsDeterministic verifies that combat +// resolution produces consistent results. +func TestDeterminism_CombatResolutionIsDeterministic(t *testing.T) { + runCombat := func() (alive0, alive1 int) { + config := DefaultConfig() + config.Rows = 10 + config.Cols = 10 + rng := rand.New(rand.NewSource(42)) + gs := NewGameState(config, rng) + + p0 := gs.AddPlayer() + p1 := gs.AddPlayer() + + // 2v1 scenario + gs.SpawnBot(p0.ID, Position{5, 5}) + gs.SpawnBot(p0.ID, Position{5, 6}) + gs.SpawnBot(p1.ID, Position{5, 7}) + + gs.executeCombat() + + bots0 := gs.GetPlayerBots(p0.ID) + bots1 := gs.GetPlayerBots(p1.ID) + + alive0 = len(bots0) + alive1 = len(bots1) + + return alive0, alive1 + } + + // Run combat 10 times + for i := 0; i < 10; i++ { + a0_1, a1_1 := runCombat() + a0_2, a1_2 := runCombat() + + if a0_1 != a0_2 || a1_1 != a1_2 { + t.Errorf("Combat resolution not deterministic on iteration %d: (%v,%v) vs (%v,%v)", + i, a0_1, a1_1, a0_2, a1_2) + } + } +} + +// TestDeterminism_ReplaySerializationRoundTrip verifies that replays +// can be serialized and deserialized without data loss. +func TestDeterminism_ReplaySerializationRoundTrip(t *testing.T) { + config := DefaultConfig() + config.Rows = 10 + config.Cols = 10 + config.MaxTurns = 10 + + rng := rand.New(rand.NewSource(42)) + mr := NewMatchRunner(config, WithRNG(rng)) + + mr.AddBot(NewIdleBot(), "Player1") + mr.AddBot(NewIdleBot(), "Player2") + + _, replay1, err := mr.Run() + if err != nil { + t.Fatalf("Match failed: %v", err) + } + + // Serialize + data, err := json.Marshal(replay1) + if err != nil { + t.Fatalf("Failed to marshal replay: %v", err) + } + + // Deserialize + replay2, err := LoadReplay(data) + if err != nil { + t.Fatalf("Failed to unmarshal replay: %v", err) + } + + // Compare key fields + if replay1.MatchID != replay2.MatchID { + t.Errorf("MatchID differs: %s vs %s", replay1.MatchID, replay2.MatchID) + } + + if len(replay1.Turns) != len(replay2.Turns) { + t.Errorf("Turn count differs: %d vs %d", len(replay1.Turns), len(replay2.Turns)) + } + + for i := range replay1.Turns { + if i >= len(replay2.Turns) { + break + } + if replay1.Turns[i].Turn != replay2.Turns[i].Turn { + t.Errorf("Turn %d number differs: %d vs %d", + i, replay1.Turns[i].Turn, replay2.Turns[i].Turn) + } + if len(replay1.Turns[i].Bots) != len(replay2.Turns[i].Bots) { + t.Errorf("Turn %d bot count differs: %d vs %d", + i, len(replay1.Turns[i].Bots), len(replay2.Turns[i].Bots)) + } + } +} + +// TestDeterminism_Full500TurnMatch verifies that a full-length match +// runs to completion with deterministic results. +func TestDeterminism_Full500TurnMatch(t *testing.T) { + if testing.Short() { + t.Skip("Skipping 500-turn match in short mode") + } + + seed := int64(99999) + config := DefaultConfig() + config.Rows = 40 + config.Cols = 40 + config.MaxTurns = 500 + + runMatch := func() *MatchResult { + rng := rand.New(rand.NewSource(seed)) + mr := NewMatchRunner(config, + WithRNG(rng), + WithTimeout(1*time.Second), + ) + + mr.AddBot(NewIdleBot(), "Player1") + mr.AddBot(NewIdleBot(), "Player2") + + result, _, err := mr.Run() + if err != nil { + t.Fatalf("Match failed: %v", err) + } + return result + } + + // Run match twice + result1 := runMatch() + result2 := runMatch() + + // Compare results + if result1.Winner != result2.Winner { + t.Errorf("Winner differs: %d vs %d", result1.Winner, result2.Winner) + } + if result1.Reason != result2.Reason { + t.Errorf("Reason differs: %s vs %s", result1.Reason, result2.Reason) + } + if result1.Turns != result2.Turns { + t.Errorf("Turns differs: %d vs %d", result1.Turns, result2.Turns) + } +}