diff --git a/squad.go b/squad.go new file mode 100644 index 0000000..6468b7c --- /dev/null +++ b/squad.go @@ -0,0 +1,128 @@ +package rules + +import ( + "errors" +) + +type SquadRuleset struct { + StandardRuleset + + SquadMap map[string]string + + // These are intentionally designed so that they default to a standard game. + AllowBodyCollisions bool + SharedElimination bool + SharedHealth bool + SharedLength bool +} + +const EliminatedBySquad = "squad-eliminated" + +func (r *SquadRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) { + nextBoardState, err := r.StandardRuleset.CreateNextBoardState(prevState, moves) + if err != nil { + return nil, err + } + + // TODO: LOG? + err = r.resurrectSquadBodyCollisions(nextBoardState) + if err != nil { + return nil, err + } + + // TODO: LOG? + err = r.shareSquadAttributes(nextBoardState) + if err != nil { + return nil, err + } + + return nextBoardState, nil +} + +func (r *SquadRuleset) areSnakesOnSameSquad(snake *Snake, other *Snake) bool { + return r.areSnakeIDsOnSameSquad(snake.ID, other.ID) +} + +func (r *SquadRuleset) areSnakeIDsOnSameSquad(snakeID string, otherID string) bool { + return r.SquadMap[snakeID] == r.SquadMap[otherID] +} + +func (r *SquadRuleset) resurrectSquadBodyCollisions(b *BoardState) error { + if !r.AllowBodyCollisions { + return nil + } + + for i := 0; i < len(b.Snakes); i++ { + snake := &b.Snakes[i] + if snake.EliminatedCause == EliminatedByCollision { + if snake.EliminatedBy == "" { + return errors.New("snake eliminated by collision and eliminatedby is not set") + } + if snake.ID != snake.EliminatedBy && r.areSnakeIDsOnSameSquad(snake.ID, snake.EliminatedBy) { + snake.EliminatedCause = NotEliminated + snake.EliminatedBy = "" + } + } + } + + return nil +} + +func (r *SquadRuleset) shareSquadAttributes(b *BoardState) error { + if !(r.SharedElimination || r.SharedLength || r.SharedHealth) { + return nil + } + + for i := 0; i < len(b.Snakes); i++ { + snake := &b.Snakes[i] + if snake.EliminatedCause != NotEliminated { + continue + } + + for j := 0; j < len(b.Snakes); j++ { + other := &b.Snakes[j] + if r.areSnakesOnSameSquad(snake, other) { + if r.SharedHealth { + if snake.Health < other.Health { + snake.Health = other.Health + } + } + if r.SharedLength { + if len(snake.Body) == 0 || len(other.Body) == 0 { + return errors.New("found snake of zero length") + } + for len(snake.Body) < len(other.Body) { + r.growSnake(snake) + } + } + if r.SharedElimination { + if snake.EliminatedCause == NotEliminated && other.EliminatedCause != NotEliminated { + snake.EliminatedCause = EliminatedBySquad + // We intentionally do not set snake.EliminatedBy because there might be multiple culprits. + snake.EliminatedBy = "" + } + } + } + } + } + + return nil +} + +func (r *SquadRuleset) IsGameOver(b *BoardState) (bool, error) { + snakesRemaining := []*Snake{} + for i := 0; i < len(b.Snakes); i++ { + if b.Snakes[i].EliminatedCause == NotEliminated { + snakesRemaining = append(snakesRemaining, &b.Snakes[i]) + } + } + + for i := 0; i < len(snakesRemaining); i++ { + if !r.areSnakesOnSameSquad(snakesRemaining[i], snakesRemaining[0]) { + // There are multiple squads remaining + return false, nil + } + } + // no snakes or single squad remaining + return true, nil +} diff --git a/squad_test.go b/squad_test.go new file mode 100644 index 0000000..7b9bfd4 --- /dev/null +++ b/squad_test.go @@ -0,0 +1,340 @@ +package rules + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSquadRulesetInterface(t *testing.T) { + var _ Ruleset = (*SquadRuleset)(nil) +} + +func TestCreateNextBoardStateSanity(t *testing.T) { + boardState := &BoardState{} + r := SquadRuleset{} + _, err := r.CreateNextBoardState(boardState, []SnakeMove{}) + require.NoError(t, err) +} + +func TestResurrectSquadBodyCollisionsSanity(t *testing.T) { + boardState := &BoardState{} + r := SquadRuleset{} + err := r.resurrectSquadBodyCollisions(boardState) + require.NoError(t, err) +} + +func TestSharedAttributesSanity(t *testing.T) { + boardState := &BoardState{} + r := SquadRuleset{} + err := r.shareSquadAttributes(boardState) + require.NoError(t, err) +} + +func TestAllowBodyCollisions(t *testing.T) { + testSnakes := []struct { + SnakeID string + SquadID string + EliminatedCause string + EliminatedBy string + ExpectedCause string + ExpectedBy string + }{ + // Red Squad + {"R1", "red", NotEliminated, "", NotEliminated, ""}, + {"R2", "red", EliminatedByCollision, "R1", NotEliminated, ""}, + // Blue Squad + {"B1", "blue", EliminatedByCollision, "R1", EliminatedByCollision, "R1"}, + {"B2", "blue", EliminatedBySelfCollision, "B1", EliminatedBySelfCollision, "B1"}, + {"B4", "blue", EliminatedByOutOfBounds, "", EliminatedByOutOfBounds, ""}, + {"B3", "blue", NotEliminated, "", NotEliminated, ""}, + // More Red Squad + {"R3", "red", NotEliminated, "", NotEliminated, ""}, + {"R4", "red", EliminatedByCollision, "R4", EliminatedByCollision, "R4"}, // this is an error case but worth testing + {"R5", "red", EliminatedByCollision, "R4", NotEliminated, ""}, + // Green Squad + {"G1", "green", EliminatedByStarvation, "x", EliminatedByStarvation, "x"}, + // Yellow Squad + {"Y1", "yellow", EliminatedByCollision, "B4", EliminatedByCollision, "B4"}, + } + + boardState := &BoardState{} + squadMap := make(map[string]string) + for _, testSnake := range testSnakes { + boardState.Snakes = append(boardState.Snakes, Snake{ + ID: testSnake.SnakeID, + EliminatedCause: testSnake.EliminatedCause, + EliminatedBy: testSnake.EliminatedBy, + }) + squadMap[testSnake.SnakeID] = testSnake.SquadID + } + require.Equal(t, len(squadMap), len(boardState.Snakes), "squad map is wrong size, error in test setup") + + r := SquadRuleset{SquadMap: squadMap, AllowBodyCollisions: true} + err := r.resurrectSquadBodyCollisions(boardState) + + require.NoError(t, err) + require.Equal(t, len(boardState.Snakes), len(testSnakes)) + for i := 0; i < len(boardState.Snakes); i++ { + require.Equal( + t, + testSnakes[i].ExpectedCause, + boardState.Snakes[i].EliminatedCause, + "snake %s failed shared eliminated cause", + testSnakes[i].SnakeID, + ) + require.Equal( + t, + testSnakes[i].ExpectedBy, + boardState.Snakes[i].EliminatedBy, + "snake %s failed shared eliminated by", + testSnakes[i].SnakeID, + ) + } +} + +func TestAllowBodyCollisionsEliminatedByNotSet(t *testing.T) { + boardState := &BoardState{ + Snakes: []Snake{ + Snake{ID: "1", EliminatedCause: EliminatedByCollision}, + Snake{ID: "2"}, + }, + } + r := SquadRuleset{ + AllowBodyCollisions: true, + SquadMap: map[string]string{ + "1": "red", + "2": "red", + }, + } + err := r.resurrectSquadBodyCollisions(boardState) + require.Error(t, err) +} + +func TestShareSquadHealth(t *testing.T) { + testSnakes := []struct { + SnakeID string + SquadID string + Health int32 + ExpectedHealth int32 + }{ + // Red Squad + {"R1", "red", 11, 88}, + {"R2", "red", 22, 88}, + // Blue Squad + {"B1", "blue", 33, 333}, + {"B2", "blue", 333, 333}, + {"B3", "blue", 3, 333}, + // More Red Squad + {"R3", "red", 77, 88}, + {"R4", "red", 88, 88}, + // Green Squad + {"G1", "green", 100, 100}, + // Yellow Squad + {"Y1", "yellow", 1, 1}, + } + + boardState := &BoardState{} + squadMap := make(map[string]string) + for _, testSnake := range testSnakes { + boardState.Snakes = append(boardState.Snakes, Snake{ + ID: testSnake.SnakeID, + Health: testSnake.Health, + }) + squadMap[testSnake.SnakeID] = testSnake.SquadID + } + require.Equal(t, len(squadMap), len(boardState.Snakes), "squad map is wrong size, error in test setup") + + r := SquadRuleset{SharedHealth: true, SquadMap: squadMap} + err := r.shareSquadAttributes(boardState) + + require.NoError(t, err) + require.Equal(t, len(boardState.Snakes), len(testSnakes)) + for i := 0; i < len(boardState.Snakes); i++ { + require.Equal( + t, + testSnakes[i].ExpectedHealth, + boardState.Snakes[i].Health, + "snake %s failed shared health", + testSnakes[i].SnakeID, + ) + } +} + +func TestSharedLength(t *testing.T) { + testSnakes := []struct { + SnakeID string + SquadID string + Body []Point + ExpectedBody []Point + }{ + // Red Squad + {"R1", "red", []Point{{1, 1}}, []Point{{1, 1}, {1, 1}, {1, 1}, {1, 1}, {1, 1}}}, + {"R2", "red", []Point{{2, 2}, {2, 2}}, []Point{{2, 2}, {2, 2}, {2, 2}, {2, 2}, {2, 2}}}, + // Blue Squad + {"B1", "blue", []Point{{1, 1}, {1, 2}}, []Point{{1, 1}, {1, 2}}}, + {"B2", "blue", []Point{{2, 1}}, []Point{{2, 1}, {2, 1}}}, + {"B3", "blue", []Point{{3, 3}}, []Point{{3, 3}, {3, 3}}}, + // More Red Squad + {"R3", "red", []Point{{1, 1}, {2, 2}, {3, 3}, {4, 4}, {5, 5}}, []Point{{1, 1}, {2, 2}, {3, 3}, {4, 4}, {5, 5}}}, + {"R4", "red", []Point{{4, 4}}, []Point{{4, 4}, {4, 4}, {4, 4}, {4, 4}, {4, 4}}}, + // Green Squad + {"G1", "green", []Point{{1, 1}}, []Point{{1, 1}}}, + // Yellow Squad + {"Y1", "yellow", []Point{{1, 3}, {1, 4}, {1, 5}, {1, 6}}, []Point{{1, 3}, {1, 4}, {1, 5}, {1, 6}}}, + } + + boardState := &BoardState{} + squadMap := make(map[string]string) + for _, testSnake := range testSnakes { + boardState.Snakes = append(boardState.Snakes, Snake{ + ID: testSnake.SnakeID, + Body: testSnake.Body, + }) + squadMap[testSnake.SnakeID] = testSnake.SquadID + } + require.Equal(t, len(squadMap), len(boardState.Snakes), "squad map is wrong size, error in test setup") + + r := SquadRuleset{SharedLength: true, SquadMap: squadMap} + err := r.shareSquadAttributes(boardState) + + require.NoError(t, err) + require.Equal(t, len(boardState.Snakes), len(testSnakes)) + for i := 0; i < len(boardState.Snakes); i++ { + require.Equal( + t, + testSnakes[i].ExpectedBody, + boardState.Snakes[i].Body, + "snake %s failed shared length", + testSnakes[i].SnakeID, + ) + } +} + +func TestSharedElimination(t *testing.T) { + testSnakes := []struct { + SnakeID string + SquadID string + EliminatedCause string + EliminatedBy string + ExpectedCause string + ExpectedBy string + }{ + // Red Squad + {"R1", "red", NotEliminated, "", EliminatedBySquad, ""}, + {"R2", "red", EliminatedByHeadToHeadCollision, "y", EliminatedByHeadToHeadCollision, "y"}, + // Blue Squad + {"B1", "blue", EliminatedByOutOfBounds, "z", EliminatedByOutOfBounds, "z"}, + {"B2", "blue", NotEliminated, "", EliminatedBySquad, ""}, + {"B3", "blue", NotEliminated, "", EliminatedBySquad, ""}, + // More Red Squad + {"R3", "red", NotEliminated, "", EliminatedBySquad, ""}, + {"R4", "red", EliminatedByCollision, "B1", EliminatedByCollision, "B1"}, + // Green Squad + {"G1", "green", EliminatedByStarvation, "x", EliminatedByStarvation, "x"}, + // Yellow Squad + {"Y1", "yellow", NotEliminated, "", NotEliminated, ""}, + } + + boardState := &BoardState{} + squadMap := make(map[string]string) + for _, testSnake := range testSnakes { + boardState.Snakes = append(boardState.Snakes, Snake{ + ID: testSnake.SnakeID, + EliminatedCause: testSnake.EliminatedCause, + EliminatedBy: testSnake.EliminatedBy, + }) + squadMap[testSnake.SnakeID] = testSnake.SquadID + } + require.Equal(t, len(squadMap), len(boardState.Snakes), "squad map is wrong size, error in test setup") + + r := SquadRuleset{SharedElimination: true, SquadMap: squadMap} + err := r.shareSquadAttributes(boardState) + + require.NoError(t, err) + require.Equal(t, len(boardState.Snakes), len(testSnakes)) + for i := 0; i < len(boardState.Snakes); i++ { + require.Equal( + t, + testSnakes[i].ExpectedCause, + boardState.Snakes[i].EliminatedCause, + "snake %s failed shared eliminated cause", + testSnakes[i].SnakeID, + ) + require.Equal( + t, + testSnakes[i].ExpectedBy, + boardState.Snakes[i].EliminatedBy, + "snake %s failed shared eliminated by", + testSnakes[i].SnakeID, + ) + } +} + +func TestSharedAttributesErrorLengthZero(t *testing.T) { + boardState := &BoardState{ + Snakes: []Snake{ + Snake{ID: "1"}, + Snake{ID: "2"}, + }, + } + r := SquadRuleset{ + SharedLength: true, + SquadMap: map[string]string{ + "1": "red", + "2": "red", + }, + } + err := r.shareSquadAttributes(boardState) + require.Error(t, err) +} + +func TestSquadIsGameOver(t *testing.T) { + tests := []struct { + Snakes []Snake + SquadMap map[string]string + Expected bool + }{ + {[]Snake{}, map[string]string{}, true}, + {[]Snake{{ID: "R1"}}, map[string]string{"R1": "red"}, true}, + { + []Snake{{ID: "R1"}, {ID: "R2"}, {ID: "R3"}}, + map[string]string{"R1": "red", "R2": "red", "R3": "red"}, + true, + }, + { + []Snake{{ID: "R1"}, {ID: "B1"}}, + map[string]string{"R1": "red", "B1": "blue"}, + false, + }, + { + []Snake{{ID: "R1"}, {ID: "B1"}, {ID: "B2"}, {ID: "G1"}}, + map[string]string{"R1": "red", "B1": "blue", "B2": "blue", "G1": "green"}, + false, + }, + { + []Snake{ + {ID: "R1", EliminatedCause: EliminatedByOutOfBounds}, + {ID: "B1", EliminatedCause: EliminatedBySelfCollision, EliminatedBy: "B1"}, + {ID: "B2", EliminatedCause: EliminatedByCollision, EliminatedBy: "B2"}, + {ID: "G1"}, + }, + map[string]string{"R1": "red", "B1": "blue", "B2": "blue", "G1": "green"}, + true, + }, + } + + for _, test := range tests { + b := &BoardState{ + Height: 11, + Width: 11, + Snakes: test.Snakes, + Food: []Point{}, + } + r := SquadRuleset{SquadMap: test.SquadMap} + + actual, err := r.IsGameOver(b) + require.NoError(t, err) + require.Equal(t, test.Expected, actual) + } +}