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].ID = prevState.Snakes[i].ID
nextState.Snakes[i].Health = prevState.Snakes[i].Health nextState.Snakes[i].Health = prevState.Snakes[i].Health
nextState.Snakes[i].Body = append([]Point{}, prevState.Snakes[i].Body...) 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? // 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 { 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++ { for i := 0; i < len(b.Snakes); i++ {
if len(b.Snakes[i].Body) == 0 { snake := &b.Snakes[i]
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
if snake.EliminatedCause != NotEliminated { if snake.EliminatedCause != NotEliminated {
continue continue
} }
var newHead = Point{} if len(snake.Body) == 0 {
switch move.Move { return errors.New("found snake with zero size body")
case MoveDown: }
newHead.X = snake.Body[0].X moveFound := false
newHead.Y = snake.Body[0].Y + 1 for _, move := range moves {
case MoveLeft: if snake.ID == move.ID {
newHead.X = snake.Body[0].X - 1 moveFound = true
newHead.Y = snake.Body[0].Y break
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 if !moveFound {
newHead.Y = snake.Body[0].Y + dY 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 for _, move := range moves {
snake.Body = append([]Point{newHead}, snake.Body[:len(snake.Body)-1]...) 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 return nil
} }
@ -331,9 +337,13 @@ func (r *StandardRuleset) maybeEliminateSnakes(b *BoardState) error {
return lenI > lenJ 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++ { for i := 0; i < len(b.Snakes); i++ {
snake := &b.Snakes[i] snake := &b.Snakes[i]
if snake.EliminatedCause != NotEliminated {
continue
}
if len(snake.Body) <= 0 { if len(snake.Body) <= 0 {
return errors.New("snake is length zero") return errors.New("snake is length zero")
} }
@ -347,40 +357,90 @@ func (r *StandardRuleset) maybeEliminateSnakes(b *BoardState) error {
snake.EliminatedCause = EliminatedByOutOfBounds snake.EliminatedCause = EliminatedByOutOfBounds
continue 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 // Check for self-collisions first
if r.snakeHasBodyCollided(snake, snake) { if r.snakeHasBodyCollided(snake, snake) {
snake.EliminatedCause = EliminatedBySelfCollision collisionEliminations = append(collisionEliminations, CollisionElimination{
snake.EliminatedBy = snake.ID ID: snake.ID,
Cause: EliminatedBySelfCollision,
By: snake.ID,
})
continue continue
} }
// Check for body collisions with other snakes second // Check for body collisions with other snakes second
hasBodyCollided := false
for _, otherIndex := range snakeIndicesByLength { for _, otherIndex := range snakeIndicesByLength {
other := &b.Snakes[otherIndex] other := &b.Snakes[otherIndex]
if snake.ID == other.ID { if other.EliminatedCause != NotEliminated {
continue continue
} }
if r.snakeHasBodyCollided(snake, other) { if snake.ID != other.ID && r.snakeHasBodyCollided(snake, other) {
snake.EliminatedCause = EliminatedByCollision collisionEliminations = append(collisionEliminations, CollisionElimination{
snake.EliminatedBy = other.ID ID: snake.ID,
Cause: EliminatedByCollision,
By: other.ID,
})
hasBodyCollided = true
break break
} }
} }
if snake.EliminatedCause != NotEliminated { if hasBodyCollided {
continue continue
} }
// Check for head-to-heads last // Check for head-to-heads last
hasHeadCollided := false
for _, otherIndex := range snakeIndicesByLength { for _, otherIndex := range snakeIndicesByLength {
other := &b.Snakes[otherIndex] other := &b.Snakes[otherIndex]
if other.EliminatedCause != NotEliminated {
continue
}
if snake.ID != other.ID && r.snakeHasLostHeadToHead(snake, other) { if snake.ID != other.ID && r.snakeHasLostHeadToHead(snake, other) {
snake.EliminatedCause = EliminatedByHeadToHeadCollision collisionEliminations = append(collisionEliminations, CollisionElimination{
snake.EliminatedBy = other.ID 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 break
} }
} }
} }
return nil return nil
} }

View file

@ -487,7 +487,7 @@ func TestCreateNextBoardState(t *testing.T) {
Food: []Point{{0, 0}, {1, 0}}, Food: []Point{{0, 0}, {1, 0}},
}, },
[]SnakeMove{}, []SnakeMove{},
errors.New("not enough snake moves"), errors.New("move not provided for snake"),
nil, nil,
}, },
{ {
@ -530,12 +530,19 @@ func TestCreateNextBoardState(t *testing.T) {
Body: []Point{{3, 4}, {3, 3}}, Body: []Point{{3, 4}, {3, 3}},
Health: 100, Health: 100,
}, },
{
ID: "three",
Body: []Point{},
Health: 100,
EliminatedCause: EliminatedByOutOfBounds,
},
}, },
Food: []Point{{0, 0}, {1, 0}}, Food: []Point{{0, 0}, {1, 0}},
}, },
[]SnakeMove{ []SnakeMove{
{ID: "one", Move: MoveUp}, {ID: "one", Move: MoveUp},
{ID: "two", Move: MoveDown}, {ID: "two", Move: MoveDown},
{ID: "three", Move: MoveLeft}, // Should be ignored
}, },
nil, nil,
&BoardState{ &BoardState{
@ -552,6 +559,12 @@ func TestCreateNextBoardState(t *testing.T) {
Body: []Point{{3, 5}, {3, 4}}, Body: []Point{{3, 5}, {3, 4}},
Health: 99, Health: 99,
}, },
{
ID: "three",
Body: []Point{},
Health: 100,
EliminatedCause: EliminatedByOutOfBounds,
},
}, },
Food: []Point{{0, 0}}, Food: []Point{{0, 0}},
}, },
@ -561,8 +574,8 @@ func TestCreateNextBoardState(t *testing.T) {
r := StandardRuleset{} r := StandardRuleset{}
for _, test := range tests { for _, test := range tests {
nextState, err := r.CreateNextBoardState(test.prevState, test.moves) nextState, err := r.CreateNextBoardState(test.prevState, test.moves)
require.Equal(t, err, test.expectedError) require.Equal(t, test.expectedError, err)
require.Equal(t, nextState, test.expectedState) require.Equal(t, test.expectedState, nextState)
} }
} }
@ -634,7 +647,7 @@ func TestEatingOnLastMove(t *testing.T) {
func TestHeadToHeadOnFood(t *testing.T) { func TestHeadToHeadOnFood(t *testing.T) {
// We want to specifically ensure that snakes that collide head-to-head // 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 // 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). // 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, // 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. // 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 rand.Seed(0) // Seed with a value that will reliably not spawn food
r := StandardRuleset{} r := StandardRuleset{}
for _, test := range tests { for _, test := range tests {
@ -741,6 +826,7 @@ func TestHeadToHeadOnFood(t *testing.T) {
require.Equal(t, err, test.expectedError) require.Equal(t, err, test.expectedError)
require.Equal(t, nextState, test.expectedState) require.Equal(t, nextState, test.expectedState)
} }
} }
func TestMoveSnakes(t *testing.T) { func TestMoveSnakes(t *testing.T) {
@ -853,7 +939,7 @@ func TestMoveSnakesWrongID(t *testing.T) {
r := StandardRuleset{} r := StandardRuleset{}
err := r.moveSnakes(b, moves) 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) { func TestMoveSnakesNotEnoughMoves(t *testing.T) {
@ -878,10 +964,10 @@ func TestMoveSnakesNotEnoughMoves(t *testing.T) {
r := StandardRuleset{} r := StandardRuleset{}
err := r.moveSnakes(b, moves) 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{ b := &BoardState{
Snakes: []Snake{ Snakes: []Snake{
{ {
@ -903,7 +989,8 @@ func TestMoveSnakesTooManyMoves(t *testing.T) {
r := StandardRuleset{} r := StandardRuleset{}
err := r.moveSnakes(b, moves) 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) { func TestIsKnownBoardSize(t *testing.T) {
@ -1466,7 +1553,7 @@ func TestMaybeEliminateSnakesPriority(t *testing.T) {
EliminatedByHeadToHeadCollision, EliminatedByHeadToHeadCollision,
NotEliminated, NotEliminated,
}, },
[]string{"", "", "3", "1", "6", ""}, []string{"", "", "3", "3", "6", ""},
}, },
} }
@ -1476,8 +1563,8 @@ func TestMaybeEliminateSnakesPriority(t *testing.T) {
err := r.maybeEliminateSnakes(b) err := r.maybeEliminateSnakes(b)
require.NoError(t, err) require.NoError(t, err)
for i, snake := range b.Snakes { for i, snake := range b.Snakes {
require.Equal(t, test.ExpectedEliminatedCauses[i], snake.EliminatedCause) require.Equal(t, test.ExpectedEliminatedCauses[i], snake.EliminatedCause, snake.ID)
require.Equal(t, test.ExpectedEliminatedBy[i], snake.EliminatedBy) require.Equal(t, test.ExpectedEliminatedBy[i], snake.EliminatedBy, snake.ID)
} }
} }
} }