Allow previous game state to include eliminated snakes. Fixes #19.

This commit is contained in:
Brad Van Vugt 2020-09-10 11:49:41 -07:00
parent 5fecc99934
commit 92592c2aba
2 changed files with 221 additions and 74 deletions

View file

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

View file

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