diff --git a/standard.go b/standard.go index 1651eac..eed4c5d 100644 --- a/standard.go +++ b/standard.go @@ -201,6 +201,8 @@ func (r *StandardRuleset) CreateNextBoardState(prevState *BoardState, moves []Sn nextState.Snakes[i].ID = prevState.Snakes[i].ID nextState.Snakes[i].Health = prevState.Snakes[i].Health nextState.Snakes[i].Body = append([]Point{}, prevState.Snakes[i].Body...) + nextState.Snakes[i].EliminatedCause = prevState.Snakes[i].EliminatedCause + nextState.Snakes[i].EliminatedBy = prevState.Snakes[i].EliminatedBy } // TODO: Gut check the BoardState? @@ -244,67 +246,71 @@ func (r *StandardRuleset) CreateNextBoardState(prevState *BoardState, moves []Sn } func (r *StandardRuleset) moveSnakes(b *BoardState, moves []SnakeMove) error { + // Sanity check that all non-eliminated snakes have moves and bodies. for i := 0; i < len(b.Snakes); i++ { - if len(b.Snakes[i].Body) == 0 { - return errors.New("found snake with zero size body") - } - } - if len(moves) < len(b.Snakes) { - return errors.New("not enough snake moves") - } - if len(moves) > len(b.Snakes) { - return errors.New("too many snake moves") - } - - for _, move := range moves { - var snake *Snake - for i := 0; i < len(b.Snakes); i++ { - if b.Snakes[i].ID == move.ID { - snake = &b.Snakes[i] - } - } - if snake == nil { - return errors.New("snake not found for move") - } - - // Do not move eliminated snakes + snake := &b.Snakes[i] if snake.EliminatedCause != NotEliminated { continue } - var newHead = Point{} - switch move.Move { - case MoveDown: - newHead.X = snake.Body[0].X - newHead.Y = snake.Body[0].Y + 1 - case MoveLeft: - newHead.X = snake.Body[0].X - 1 - newHead.Y = snake.Body[0].Y - case MoveRight: - newHead.X = snake.Body[0].X + 1 - newHead.Y = snake.Body[0].Y - case MoveUp: - newHead.X = snake.Body[0].X - newHead.Y = snake.Body[0].Y - 1 - default: - // Default to UP - var dX int32 = 0 - var dY int32 = -1 - // If neck is available, use neck to determine last direction - if len(snake.Body) >= 2 { - dX = snake.Body[0].X - snake.Body[1].X - dY = snake.Body[0].Y - snake.Body[1].Y - if dX == 0 && dY == 0 { - dY = -1 // Move up if no last move was made - } + if len(snake.Body) == 0 { + return errors.New("found snake with zero size body") + } + moveFound := false + for _, move := range moves { + if snake.ID == move.ID { + moveFound = true + break } - // Apply - newHead.X = snake.Body[0].X + dX - newHead.Y = snake.Body[0].Y + dY + } + if !moveFound { + return errors.New("move not provided for snake") + } + } + + for i := 0; i < len(b.Snakes); i++ { + snake := &b.Snakes[i] + if snake.EliminatedCause != NotEliminated { + continue } - // Append new head, pop old tail - snake.Body = append([]Point{newHead}, snake.Body[:len(snake.Body)-1]...) + for _, move := range moves { + if move.ID == snake.ID { + var newHead = Point{} + switch move.Move { + case MoveDown: + newHead.X = snake.Body[0].X + newHead.Y = snake.Body[0].Y + 1 + case MoveLeft: + newHead.X = snake.Body[0].X - 1 + newHead.Y = snake.Body[0].Y + case MoveRight: + newHead.X = snake.Body[0].X + 1 + newHead.Y = snake.Body[0].Y + case MoveUp: + newHead.X = snake.Body[0].X + newHead.Y = snake.Body[0].Y - 1 + default: + // Default to UP + var dX int32 = 0 + var dY int32 = -1 + // If neck is available, use neck to determine last direction + if len(snake.Body) >= 2 { + dX = snake.Body[0].X - snake.Body[1].X + dY = snake.Body[0].Y - snake.Body[1].Y + if dX == 0 && dY == 0 { + dY = -1 // Move up if no last move was made + } + } + // Apply + newHead.X = snake.Body[0].X + dX + newHead.Y = snake.Body[0].Y + dY + } + + // Append new head, pop old tail + snake.Body = append([]Point{newHead}, snake.Body[:len(snake.Body)-1]...) + } + } } return nil } @@ -331,9 +337,13 @@ func (r *StandardRuleset) maybeEliminateSnakes(b *BoardState) error { return lenI > lenJ }) - // Iterate through snakes checking for eliminations. + // First, iterate over all non-eliminated snakes and eliminate the ones + // that are out of health or have moved out of bounds. for i := 0; i < len(b.Snakes); i++ { snake := &b.Snakes[i] + if snake.EliminatedCause != NotEliminated { + continue + } if len(snake.Body) <= 0 { return errors.New("snake is length zero") } @@ -347,40 +357,90 @@ func (r *StandardRuleset) maybeEliminateSnakes(b *BoardState) error { snake.EliminatedCause = EliminatedByOutOfBounds continue } + } + + // Next, look for any collisions. Note we apply collision eliminations + // after this check so that snakes can collide with each other and be properly eliminated. + type CollisionElimination struct { + ID string + Cause string + By string + } + collisionEliminations := []CollisionElimination{} + for i := 0; i < len(b.Snakes); i++ { + snake := &b.Snakes[i] + if snake.EliminatedCause != NotEliminated { + continue + } + if len(snake.Body) <= 0 { + return errors.New("snake is length zero") + } // Check for self-collisions first if r.snakeHasBodyCollided(snake, snake) { - snake.EliminatedCause = EliminatedBySelfCollision - snake.EliminatedBy = snake.ID + collisionEliminations = append(collisionEliminations, CollisionElimination{ + ID: snake.ID, + Cause: EliminatedBySelfCollision, + By: snake.ID, + }) continue } // Check for body collisions with other snakes second + hasBodyCollided := false for _, otherIndex := range snakeIndicesByLength { other := &b.Snakes[otherIndex] - if snake.ID == other.ID { + if other.EliminatedCause != NotEliminated { continue } - if r.snakeHasBodyCollided(snake, other) { - snake.EliminatedCause = EliminatedByCollision - snake.EliminatedBy = other.ID + if snake.ID != other.ID && r.snakeHasBodyCollided(snake, other) { + collisionEliminations = append(collisionEliminations, CollisionElimination{ + ID: snake.ID, + Cause: EliminatedByCollision, + By: other.ID, + }) + hasBodyCollided = true break } } - if snake.EliminatedCause != NotEliminated { + if hasBodyCollided { continue } // Check for head-to-heads last + hasHeadCollided := false for _, otherIndex := range snakeIndicesByLength { other := &b.Snakes[otherIndex] + if other.EliminatedCause != NotEliminated { + continue + } if snake.ID != other.ID && r.snakeHasLostHeadToHead(snake, other) { - snake.EliminatedCause = EliminatedByHeadToHeadCollision - snake.EliminatedBy = other.ID + collisionEliminations = append(collisionEliminations, CollisionElimination{ + ID: snake.ID, + Cause: EliminatedByHeadToHeadCollision, + By: other.ID, + }) + hasHeadCollided = true + break + } + } + if hasHeadCollided { + continue + } + } + + // Apply collision eliminations + for _, elimination := range collisionEliminations { + for i := 0; i < len(b.Snakes); i++ { + snake := &b.Snakes[i] + if snake.ID == elimination.ID { + snake.EliminatedCause = elimination.Cause + snake.EliminatedBy = elimination.By break } } } + return nil } diff --git a/standard_test.go b/standard_test.go index 8884f43..f5a6bd8 100644 --- a/standard_test.go +++ b/standard_test.go @@ -487,7 +487,7 @@ func TestCreateNextBoardState(t *testing.T) { Food: []Point{{0, 0}, {1, 0}}, }, []SnakeMove{}, - errors.New("not enough snake moves"), + errors.New("move not provided for snake"), nil, }, { @@ -530,12 +530,19 @@ func TestCreateNextBoardState(t *testing.T) { Body: []Point{{3, 4}, {3, 3}}, Health: 100, }, + { + ID: "three", + Body: []Point{}, + Health: 100, + EliminatedCause: EliminatedByOutOfBounds, + }, }, Food: []Point{{0, 0}, {1, 0}}, }, []SnakeMove{ {ID: "one", Move: MoveUp}, {ID: "two", Move: MoveDown}, + {ID: "three", Move: MoveLeft}, // Should be ignored }, nil, &BoardState{ @@ -552,6 +559,12 @@ func TestCreateNextBoardState(t *testing.T) { Body: []Point{{3, 5}, {3, 4}}, Health: 99, }, + { + ID: "three", + Body: []Point{}, + Health: 100, + EliminatedCause: EliminatedByOutOfBounds, + }, }, Food: []Point{{0, 0}}, }, @@ -561,8 +574,8 @@ func TestCreateNextBoardState(t *testing.T) { r := StandardRuleset{} for _, test := range tests { nextState, err := r.CreateNextBoardState(test.prevState, test.moves) - require.Equal(t, err, test.expectedError) - require.Equal(t, nextState, test.expectedState) + require.Equal(t, test.expectedError, err) + require.Equal(t, test.expectedState, nextState) } } @@ -634,7 +647,7 @@ func TestEatingOnLastMove(t *testing.T) { func TestHeadToHeadOnFood(t *testing.T) { // We want to specifically ensure that snakes that collide head-to-head // on top of food successfully remove the food - that's the core behaviour this test - // is enforicing. There's a known side effect of this though, in that both snakes will + // is enforcing. There's a known side effect of this though, in that both snakes will // have eaten prior to being evaluated on the head-to-head (+1 length, full health). // We're okay with that since it does not impact the result of the head-to-head, // however that behaviour could change in the future and this test could be updated. @@ -734,6 +747,78 @@ func TestHeadToHeadOnFood(t *testing.T) { }, } + rand.Seed(0) // Seed with a value that will reliably not spawn food + r := StandardRuleset{} + for _, test := range tests { + nextState, err := r.CreateNextBoardState(test.prevState, test.moves) + require.Equal(t, test.expectedError, err) + require.Equal(t, test.expectedState, nextState) + } +} + +func TestRegressionIssue19(t *testing.T) { + // Eliminated snakes passed to CreateNextBoardState should not impact next game state + tests := []struct { + prevState *BoardState + moves []SnakeMove + expectedError error + expectedState *BoardState + }{ + { + &BoardState{ + Width: 10, + Height: 10, + Snakes: []Snake{ + { + ID: "one", + Body: []Point{{0, 2}, {0, 1}, {0, 0}}, + Health: 100, + }, + { + ID: "two", + Body: []Point{{0, 5}, {0, 6}, {0, 7}}, + Health: 100, + }, + { + ID: "eliminated", + Body: []Point{{0, 0}, {0, 1}, {0, 2}, {0, 3}, {0, 4}, {0, 5}, {0, 6}}, + Health: 0, + EliminatedCause: EliminatedByStarvation, + }, + }, + Food: []Point{{9, 9}}, + }, + []SnakeMove{ + {ID: "one", Move: MoveDown}, + {ID: "two", Move: MoveUp}, + }, + nil, + &BoardState{ + Width: 10, + Height: 10, + Snakes: []Snake{ + { + ID: "one", + Body: []Point{{0, 3}, {0, 2}, {0, 1}}, + Health: 99, + }, + { + ID: "two", + Body: []Point{{0, 4}, {0, 5}, {0, 6}}, + Health: 99, + }, + { + ID: "eliminated", + Body: []Point{{0, 0}, {0, 1}, {0, 2}, {0, 3}, {0, 4}, {0, 5}, {0, 6}}, + Health: 0, + EliminatedCause: EliminatedByStarvation, + }, + }, + Food: []Point{{9, 9}}, + }, + }, + } + rand.Seed(0) // Seed with a value that will reliably not spawn food r := StandardRuleset{} for _, test := range tests { @@ -741,6 +826,7 @@ func TestHeadToHeadOnFood(t *testing.T) { require.Equal(t, err, test.expectedError) require.Equal(t, nextState, test.expectedState) } + } func TestMoveSnakes(t *testing.T) { @@ -853,7 +939,7 @@ func TestMoveSnakesWrongID(t *testing.T) { r := StandardRuleset{} err := r.moveSnakes(b, moves) - require.Equal(t, err, errors.New("snake not found for move")) + require.Equal(t, errors.New("move not provided for snake"), err) } func TestMoveSnakesNotEnoughMoves(t *testing.T) { @@ -878,10 +964,10 @@ func TestMoveSnakesNotEnoughMoves(t *testing.T) { r := StandardRuleset{} err := r.moveSnakes(b, moves) - require.Equal(t, err, errors.New("not enough snake moves")) + require.Equal(t, errors.New("move not provided for snake"), err) } -func TestMoveSnakesTooManyMoves(t *testing.T) { +func TestMoveSnakesExtraMovesIgnored(t *testing.T) { b := &BoardState{ Snakes: []Snake{ { @@ -903,7 +989,8 @@ func TestMoveSnakesTooManyMoves(t *testing.T) { r := StandardRuleset{} err := r.moveSnakes(b, moves) - require.Equal(t, err, errors.New("too many snake moves")) + require.NoError(t, err) + require.Equal(t, []Point{{1, 0}}, b.Snakes[0].Body) } func TestIsKnownBoardSize(t *testing.T) { @@ -1466,7 +1553,7 @@ func TestMaybeEliminateSnakesPriority(t *testing.T) { EliminatedByHeadToHeadCollision, NotEliminated, }, - []string{"", "", "3", "1", "6", ""}, + []string{"", "", "3", "3", "6", ""}, }, } @@ -1476,8 +1563,8 @@ func TestMaybeEliminateSnakesPriority(t *testing.T) { err := r.maybeEliminateSnakes(b) require.NoError(t, err) for i, snake := range b.Snakes { - require.Equal(t, test.ExpectedEliminatedCauses[i], snake.EliminatedCause) - require.Equal(t, test.ExpectedEliminatedBy[i], snake.EliminatedBy) + require.Equal(t, test.ExpectedEliminatedCauses[i], snake.EliminatedCause, snake.ID) + require.Equal(t, test.ExpectedEliminatedBy[i], snake.EliminatedBy, snake.ID) } } }