diff --git a/ruleset.go b/ruleset.go index 62587df..9765fd0 100644 --- a/ruleset.go +++ b/ruleset.go @@ -34,5 +34,5 @@ type SnakeMove struct { type Ruleset interface { CreateInitialBoardState(width int32, height int32, snakeIDs []string) (*BoardState, error) - ResolveMoves(prevState *BoardState, moves []SnakeMove) (*BoardState, error) + CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) } diff --git a/standard.go b/standard.go index ddf29bb..ebc32ca 100644 --- a/standard.go +++ b/standard.go @@ -131,7 +131,7 @@ func (r *StandardRuleset) isKnownBoardSize(b *BoardState) bool { return false } -func (r *StandardRuleset) ResolveMoves(prevState *BoardState, moves []SnakeMove) (*BoardState, error) { +func (r *StandardRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) { // We specifically want to copy prevState, so as not to alter it directly. nextState := &BoardState{ Height: prevState.Height, @@ -160,7 +160,7 @@ func (r *StandardRuleset) ResolveMoves(prevState *BoardState, moves []SnakeMove) } // TODO: LOG? - err = r.eliminateSnakes(nextState) + err = r.maybeEliminateSnakes(nextState) if err != nil { return nil, err } @@ -172,7 +172,7 @@ func (r *StandardRuleset) ResolveMoves(prevState *BoardState, moves []SnakeMove) // of equal length actually show length + 1 // TODO: LOG? - err = r.feedSnakes(nextState) + err = r.maybeFeedSnakes(nextState) if err != nil { return nil, err } @@ -256,7 +256,7 @@ func (r *StandardRuleset) reduceSnakeHealth(b *BoardState) error { return nil } -func (r *StandardRuleset) eliminateSnakes(b *BoardState) error { +func (r *StandardRuleset) maybeEliminateSnakes(b *BoardState) error { // First order snake indices by length. // In multi-collision scenarios we want to always attribute elimination to the longest snake. snakeIndicesByLength := make([]int, len(b.Snakes)) @@ -351,7 +351,7 @@ func (r *StandardRuleset) snakeHasLostHeadToHead(s *Snake, other *Snake) bool { return false } -func (r *StandardRuleset) feedSnakes(b *BoardState) error { +func (r *StandardRuleset) maybeFeedSnakes(b *BoardState) error { newFood := []Point{} for _, food := range b.Food { foodHasBeenEaten := false @@ -364,10 +364,8 @@ func (r *StandardRuleset) feedSnakes(b *BoardState) error { } if snake.Body[0].X == food.X && snake.Body[0].Y == food.Y { + r.feedSnake(snake) foodHasBeenEaten = true - // Update snake - snake.Body = append(snake.Body, snake.Body[len(snake.Body)-1]) - snake.Health = SnakeMaxHealth } } // Persist food to next BoardState if not eaten @@ -380,6 +378,17 @@ func (r *StandardRuleset) feedSnakes(b *BoardState) error { return nil } +func (r *StandardRuleset) feedSnake(snake *Snake) { + r.growSnake(snake) + snake.Health = SnakeMaxHealth +} + +func (r *StandardRuleset) growSnake(snake *Snake) { + if len(snake.Body) > 0 { + snake.Body = append(snake.Body, snake.Body[len(snake.Body)-1]) + } +} + func (r *StandardRuleset) maybeSpawnFood(b *BoardState) error { if len(b.Food) == 0 || rand.Float32() <= FoodSpawnChance { return r.spawnFood(b, 1) diff --git a/standard_test.go b/standard_test.go index 5f3e7b9..4f4d269 100644 --- a/standard_test.go +++ b/standard_test.go @@ -20,7 +20,7 @@ func TestSanity(t *testing.T) { require.Len(t, state.Food, 0) require.Len(t, state.Snakes, 0) - next, err := r.ResolveMoves( + next, err := r.CreateNextBoardState( &BoardState{}, []SnakeMove{}, ) @@ -244,7 +244,7 @@ func TestPlaceFood(t *testing.T) { } } -func TestResolveMoves(t *testing.T) { +func TestCreateNextBoardState(t *testing.T) { // TODO } @@ -761,7 +761,7 @@ func TestSnakeHasLostHeadToHead(t *testing.T) { } -func TestEliminateSnakes(t *testing.T) { +func TestMaybeEliminateSnakes(t *testing.T) { tests := []struct { Name string Snakes []Snake @@ -938,7 +938,7 @@ func TestEliminateSnakes(t *testing.T) { Height: 10, Snakes: test.Snakes, } - err := r.eliminateSnakes(b) + err := r.maybeEliminateSnakes(b) require.Equal(t, test.Err, err) for i, snake := range b.Snakes { require.Equal(t, test.ExpectedEliminatedCauses[i], snake.EliminatedCause) @@ -948,7 +948,7 @@ func TestEliminateSnakes(t *testing.T) { } } -func TestFeedSnakes(t *testing.T) { +func TestMaybeFeedSnakes(t *testing.T) { tests := []struct { Name string Snakes []Snake @@ -1021,7 +1021,7 @@ func TestFeedSnakes(t *testing.T) { Snakes: test.Snakes, Food: test.Food, } - err := r.feedSnakes(b) + err := r.maybeFeedSnakes(b) require.NoError(t, err, test.Name) require.Equal(t, len(test.ExpectedSnakes), len(b.Snakes), test.Name) for i := 0; i < len(b.Snakes); i++ { diff --git a/team.go b/team.go new file mode 100644 index 0000000..fc45bc7 --- /dev/null +++ b/team.go @@ -0,0 +1,110 @@ +package rules + +import ( + "errors" +) + +type TeamRuleset struct { + StandardRuleset + + TeamMap map[string]string + + // These are intentionally designed so that they default to a standard game. + AllowBodyCollisions bool + SharedElimination bool + SharedHealth bool + SharedLength bool +} + +const EliminatedByTeam = "team-eliminated" + +func (r *TeamRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) { + nextBoardState, err := r.StandardRuleset.CreateNextBoardState(prevState, moves) + if err != nil { + return nil, err + } + + // TODO: LOG? + err = r.resurrectTeamBodyCollisions(nextBoardState) + if err != nil { + return nil, err + } + + // TODO: LOG? + err = r.shareTeamAttributes(nextBoardState) + if err != nil { + return nil, err + } + + return nextBoardState, nil +} + +func (r *TeamRuleset) areSnakesOnSameTeam(snake *Snake, other *Snake) bool { + return r.areSnakeIDsOnSameTeam(snake.ID, other.ID) +} + +func (r *TeamRuleset) areSnakeIDsOnSameTeam(snakeID string, otherID string) bool { + return snakeID != otherID && r.TeamMap[snakeID] == r.TeamMap[otherID] +} + +func (r *TeamRuleset) resurrectTeamBodyCollisions(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 r.areSnakeIDsOnSameTeam(snake.ID, snake.EliminatedBy) { + snake.EliminatedCause = NotEliminated + snake.EliminatedBy = "" + } + } + } + + return nil +} + +func (r *TeamRuleset) shareTeamAttributes(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.areSnakesOnSameTeam(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 = EliminatedByTeam + // We intentionally do not set snake.EliminatedBy because there might be multiple culprits. + snake.EliminatedBy = "" + } + } + } + } + } + + return nil +} diff --git a/team_test.go b/team_test.go new file mode 100644 index 0000000..76e9d46 --- /dev/null +++ b/team_test.go @@ -0,0 +1,286 @@ +package rules + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCreateNextBoardStateSanity(t *testing.T) { + boardState := &BoardState{} + r := TeamRuleset{} + _, err := r.CreateNextBoardState(boardState, []SnakeMove{}) + require.NoError(t, err) +} + +func TestResurrectTeamBodyCollisionsSanity(t *testing.T) { + boardState := &BoardState{} + r := TeamRuleset{} + err := r.resurrectTeamBodyCollisions(boardState) + require.NoError(t, err) +} + +func TestSharedAttributesSanity(t *testing.T) { + boardState := &BoardState{} + r := TeamRuleset{} + err := r.shareTeamAttributes(boardState) + require.NoError(t, err) +} + +func TestAllowBodyCollisions(t *testing.T) { + testSnakes := []struct { + SnakeID string + TeamID string + EliminatedCause string + EliminatedBy string + ExpectedCause string + ExpectedBy string + }{ + // Team Red + {"R1", "red", NotEliminated, "", NotEliminated, ""}, + {"R2", "red", EliminatedByCollision, "R1", NotEliminated, ""}, + // Team Blue + {"B1", "blue", EliminatedByCollision, "R1", EliminatedByCollision, "R1"}, + {"B2", "blue", EliminatedBySelfCollision, "B1", EliminatedBySelfCollision, "B1"}, + {"B4", "blue", EliminatedByOutOfBounds, "", EliminatedByOutOfBounds, ""}, + {"B3", "blue", NotEliminated, "", NotEliminated, ""}, + // More Team Red + {"R3", "red", NotEliminated, "", NotEliminated, ""}, + {"R4", "red", EliminatedByCollision, "R4", EliminatedByCollision, "R4"}, // this is an error case but worth testing + {"R5", "red", EliminatedByCollision, "R4", NotEliminated, ""}, + // // Team Green + {"G1", "green", EliminatedByStarvation, "x", EliminatedByStarvation, "x"}, + // // Team Yellow + {"Y1", "yellow", EliminatedByCollision, "B4", EliminatedByCollision, "B4"}, + } + + boardState := &BoardState{} + teamMap := make(map[string]string) + for _, testSnake := range testSnakes { + boardState.Snakes = append(boardState.Snakes, Snake{ + ID: testSnake.SnakeID, + EliminatedCause: testSnake.EliminatedCause, + EliminatedBy: testSnake.EliminatedBy, + }) + teamMap[testSnake.SnakeID] = testSnake.TeamID + } + require.Equal(t, len(teamMap), len(boardState.Snakes), "team map is wrong size, error in test setup") + + r := TeamRuleset{TeamMap: teamMap, AllowBodyCollisions: true} + err := r.resurrectTeamBodyCollisions(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 := TeamRuleset{ + AllowBodyCollisions: true, + TeamMap: map[string]string{ + "1": "red", + "2": "red", + }, + } + err := r.resurrectTeamBodyCollisions(boardState) + require.Error(t, err) +} + +func TestShareTeamHealth(t *testing.T) { + testSnakes := []struct { + SnakeID string + TeamID string + Health int32 + ExpectedHealth int32 + }{ + // Team Red + {"R1", "red", 11, 88}, + {"R2", "red", 22, 88}, + // Team Blue + {"B1", "blue", 33, 333}, + {"B2", "blue", 333, 333}, + {"B3", "blue", 3, 333}, + // More Team Red + {"R3", "red", 77, 88}, + {"R4", "red", 88, 88}, + // Team Green + {"G1", "green", 100, 100}, + // Team Yellow + {"Y1", "yellow", 1, 1}, + } + + boardState := &BoardState{} + teamMap := make(map[string]string) + for _, testSnake := range testSnakes { + boardState.Snakes = append(boardState.Snakes, Snake{ + ID: testSnake.SnakeID, + Health: testSnake.Health, + }) + teamMap[testSnake.SnakeID] = testSnake.TeamID + } + require.Equal(t, len(teamMap), len(boardState.Snakes), "team map is wrong size, error in test setup") + + r := TeamRuleset{SharedHealth: true, TeamMap: teamMap} + err := r.shareTeamAttributes(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 + TeamID string + Body []Point + ExpectedBody []Point + }{ + // Team Red + {"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}}}, + // Team Blue + {"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 Team Red + {"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}}}, + // Team Green + {"G1", "green", []Point{{1, 1}}, []Point{{1, 1}}}, + // Team Yellow + {"Y1", "yellow", []Point{{1, 3}, {1, 4}, {1, 5}, {1, 6}}, []Point{{1, 3}, {1, 4}, {1, 5}, {1, 6}}}, + } + + boardState := &BoardState{} + teamMap := make(map[string]string) + for _, testSnake := range testSnakes { + boardState.Snakes = append(boardState.Snakes, Snake{ + ID: testSnake.SnakeID, + Body: testSnake.Body, + }) + teamMap[testSnake.SnakeID] = testSnake.TeamID + } + require.Equal(t, len(teamMap), len(boardState.Snakes), "team map is wrong size, error in test setup") + + r := TeamRuleset{SharedLength: true, TeamMap: teamMap} + err := r.shareTeamAttributes(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 + TeamID string + EliminatedCause string + EliminatedBy string + ExpectedCause string + ExpectedBy string + }{ + // Team Red + {"R1", "red", NotEliminated, "", EliminatedByTeam, ""}, + {"R2", "red", EliminatedByHeadToHeadCollision, "y", EliminatedByHeadToHeadCollision, "y"}, + // Team Blue + {"B1", "blue", EliminatedByOutOfBounds, "z", EliminatedByOutOfBounds, "z"}, + {"B2", "blue", NotEliminated, "", EliminatedByTeam, ""}, + {"B3", "blue", NotEliminated, "", EliminatedByTeam, ""}, + // More Team Red + {"R3", "red", NotEliminated, "", EliminatedByTeam, ""}, + {"R4", "red", EliminatedByCollision, "B1", EliminatedByCollision, "B1"}, + // Team Green + {"G1", "green", EliminatedByStarvation, "x", EliminatedByStarvation, "x"}, + // Team Yellow + {"Y1", "yellow", NotEliminated, "", NotEliminated, ""}, + } + + boardState := &BoardState{} + teamMap := make(map[string]string) + for _, testSnake := range testSnakes { + boardState.Snakes = append(boardState.Snakes, Snake{ + ID: testSnake.SnakeID, + EliminatedCause: testSnake.EliminatedCause, + EliminatedBy: testSnake.EliminatedBy, + }) + teamMap[testSnake.SnakeID] = testSnake.TeamID + } + require.Equal(t, len(teamMap), len(boardState.Snakes), "team map is wrong size, error in test setup") + + r := TeamRuleset{SharedElimination: true, TeamMap: teamMap} + err := r.shareTeamAttributes(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 := TeamRuleset{ + SharedLength: true, + TeamMap: map[string]string{ + "1": "red", + "2": "red", + }, + } + err := r.shareTeamAttributes(boardState) + require.Error(t, err) +}