ai-code-battle/engine/determinism_test.go
jedarden 890785c5c4 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>
2026-03-24 03:44:44 -04:00

383 lines
9.6 KiB
Go

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