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 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-03-24 03:44:44 -04:00
parent 6d3f3506b3
commit 890785c5c4
4 changed files with 506 additions and 11 deletions

View file

@ -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

View file

@ -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
}

View file

@ -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)

383
engine/determinism_test.go Normal file
View file

@ -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)
}
}