Byte-snake-engine/standard_test.go
Torben 397d925110
DEV-765 pipeline refactor (#64)
Refactor rulesets into smaller composable operations

In order to mix up the functionality from different rulesets like Solo, Royale, etc. the code in these classes needs to be broken up into small functions that can be composed in a pipeline to make a custom game mode.
2022-03-16 16:58:05 -07:00

1623 lines
38 KiB
Go

package rules
import (
"math"
"math/rand"
"testing"
"github.com/stretchr/testify/require"
)
func TestStandardRulesetInterface(t *testing.T) {
var _ Ruleset = (*StandardRuleset)(nil)
}
func TestSanity(t *testing.T) {
r := StandardRuleset{}
state, err := CreateDefaultBoardState(0, 0, []string{})
require.NoError(t, err)
require.NotNil(t, state)
state, err = r.ModifyInitialBoardState(state)
require.NoError(t, err)
require.NotNil(t, state)
require.Equal(t, int32(0), state.Width)
require.Equal(t, int32(0), state.Height)
require.Len(t, state.Food, 0)
require.Len(t, state.Snakes, 0)
next, err := r.CreateNextBoardState(
&BoardState{},
[]SnakeMove{},
)
require.NoError(t, err)
require.NotNil(t, next)
require.Equal(t, int32(0), state.Width)
require.Equal(t, int32(0), state.Height)
require.Len(t, state.Snakes, 0)
}
func TestStandardName(t *testing.T) {
r := StandardRuleset{}
require.Equal(t, "standard", r.Name())
}
// Checks that the error for a snake missing a move is returned
var standardCaseErrNoMoveFound = gameTestCase{
"Standard Case Error No Move Found",
&BoardState{
Width: 10,
Height: 10,
Snakes: []Snake{
{
ID: "one",
Body: []Point{{1, 1}, {1, 2}},
Health: 100,
},
{
ID: "two",
Body: []Point{{3, 4}, {3, 3}},
Health: 100,
},
},
Food: []Point{{0, 0}, {1, 0}},
Hazards: []Point{},
},
[]SnakeMove{
{ID: "one", Move: MoveUp},
},
ErrorNoMoveFound,
nil,
}
// Checks that the error for a snake with no points is returned
var standardCaseErrZeroLengthSnake = gameTestCase{
"Standard Case Error Zero Length Snake",
&BoardState{
Width: 10,
Height: 10,
Snakes: []Snake{
{
ID: "one",
Body: []Point{{1, 1}, {1, 2}},
Health: 100,
},
{
ID: "two",
Body: []Point{},
Health: 100,
},
},
Food: []Point{{0, 0}, {1, 0}},
Hazards: []Point{},
},
[]SnakeMove{
{ID: "one", Move: MoveUp},
{ID: "two", Move: MoveDown},
},
ErrorZeroLengthSnake,
nil,
}
// Checks a basic state where a snake moves, eats and grows
var standardCaseMoveEatAndGrow = gameTestCase{
"Standard Case Move Eat and Grow",
&BoardState{
Width: 10,
Height: 10,
Snakes: []Snake{
{
ID: "one",
Body: []Point{{1, 1}, {1, 2}},
Health: 100,
},
{
ID: "two",
Body: []Point{{3, 4}, {3, 3}},
Health: 100,
},
{
ID: "three",
Body: []Point{},
Health: 100,
EliminatedCause: EliminatedByOutOfBounds,
},
},
Food: []Point{{0, 0}, {1, 0}},
Hazards: []Point{},
},
[]SnakeMove{
{ID: "one", Move: MoveDown},
{ID: "two", Move: MoveUp},
{ID: "three", Move: MoveLeft}, // Should be ignored
},
nil,
&BoardState{
Width: 10,
Height: 10,
Snakes: []Snake{
{
ID: "one",
Body: []Point{{1, 0}, {1, 1}, {1, 1}},
Health: 100,
},
{
ID: "two",
Body: []Point{{3, 5}, {3, 4}},
Health: 99,
},
{
ID: "three",
Body: []Point{},
Health: 100,
EliminatedCause: EliminatedByOutOfBounds,
},
},
Food: []Point{{0, 0}},
Hazards: []Point{},
},
}
// Checks a basic state where two snakes of equal sizes collide, and both should
// be eliminated as a result.
var standardMoveAndCollideMAD = gameTestCase{
"Standard Case Move and Collide",
&BoardState{
Width: 10,
Height: 10,
Snakes: []Snake{
{
ID: "one",
Body: []Point{{1, 1}, {2, 1}},
Health: 99,
},
{
ID: "two",
Body: []Point{{1, 2}, {2, 2}},
Health: 99,
},
},
Food: []Point{},
Hazards: []Point{},
},
[]SnakeMove{
{ID: "one", Move: MoveUp},
{ID: "two", Move: MoveDown},
},
nil,
&BoardState{
Width: 10,
Height: 10,
Snakes: []Snake{
{
ID: "one",
Body: []Point{{1, 2}, {1, 1}},
Health: 98,
EliminatedCause: EliminatedByCollision,
EliminatedBy: "two",
},
{
ID: "two",
Body: []Point{{1, 1}, {1, 2}},
Health: 98,
EliminatedCause: EliminatedByCollision,
EliminatedBy: "one",
},
},
Food: []Point{},
Hazards: []Point{},
},
}
func TestStandardCreateNextBoardState(t *testing.T) {
cases := []gameTestCase{
standardCaseErrNoMoveFound,
standardCaseErrZeroLengthSnake,
standardCaseMoveEatAndGrow,
standardMoveAndCollideMAD,
}
r := StandardRuleset{}
for _, gc := range cases {
gc.requireValidNextState(t, &r)
}
}
func TestEatingOnLastMove(t *testing.T) {
// We want to specifically ensure that snakes eating food on their last turn
// survive. It used to be that this wasn't the case, and snakes were eliminated
// if they moved onto food with their final move. This behaviour wasn't "wrong" or incorrect,
// it just was less fun to watch. So let's ensure we're always giving snakes every possible
// changes to reach food before eliminating them.
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: 1,
},
{
ID: "two",
Body: []Point{{3, 2}, {3, 3}, {3, 4}},
Health: 1,
},
},
Food: []Point{{0, 3}, {9, 9}},
},
[]SnakeMove{
{ID: "one", Move: MoveUp},
{ID: "two", Move: MoveDown},
},
nil,
&BoardState{
Width: 10,
Height: 10,
Snakes: []Snake{
{
ID: "one",
Body: []Point{{0, 3}, {0, 2}, {0, 1}, {0, 1}},
Health: 100,
},
{
ID: "two",
Body: []Point{{3, 1}, {3, 2}, {3, 3}},
Health: 0,
EliminatedCause: EliminatedByOutOfHealth,
},
},
Food: []Point{{9, 9}},
},
},
}
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, err, test.expectedError)
if test.expectedState != nil {
require.Equal(t, test.expectedState.Width, nextState.Width)
require.Equal(t, test.expectedState.Height, nextState.Height)
require.Equal(t, test.expectedState.Food, nextState.Food)
require.Equal(t, test.expectedState.Snakes, nextState.Snakes)
}
}
}
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 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.
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: 10,
},
{
ID: "two",
Body: []Point{{0, 4}, {0, 5}, {0, 6}},
Health: 10,
},
},
Food: []Point{{0, 3}, {9, 9}},
},
[]SnakeMove{
{ID: "one", Move: MoveUp},
{ID: "two", Move: MoveDown},
},
nil,
&BoardState{
Width: 10,
Height: 10,
Snakes: []Snake{
{
ID: "one",
Body: []Point{{0, 3}, {0, 2}, {0, 1}, {0, 1}},
Health: 100,
EliminatedCause: EliminatedByHeadToHeadCollision,
EliminatedBy: "two",
},
{
ID: "two",
Body: []Point{{0, 3}, {0, 4}, {0, 5}, {0, 5}},
Health: 100,
EliminatedCause: EliminatedByHeadToHeadCollision,
EliminatedBy: "one",
},
},
Food: []Point{{9, 9}},
},
},
{
&BoardState{
Width: 10,
Height: 10,
Snakes: []Snake{
{
ID: "one",
Body: []Point{{0, 2}, {0, 1}, {0, 0}},
Health: 10,
},
{
ID: "two",
Body: []Point{{0, 4}, {0, 5}, {0, 6}, {0, 7}},
Health: 10,
},
},
Food: []Point{{0, 3}, {9, 9}},
},
[]SnakeMove{
{ID: "one", Move: MoveUp},
{ID: "two", Move: MoveDown},
},
nil,
&BoardState{
Width: 10,
Height: 10,
Snakes: []Snake{
{
ID: "one",
Body: []Point{{0, 3}, {0, 2}, {0, 1}, {0, 1}},
Health: 100,
EliminatedCause: EliminatedByHeadToHeadCollision,
EliminatedBy: "two",
},
{
ID: "two",
Body: []Point{{0, 3}, {0, 4}, {0, 5}, {0, 6}, {0, 6}},
Health: 100,
},
},
Food: []Point{{9, 9}},
},
},
}
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)
if test.expectedState != nil {
require.Equal(t, test.expectedState.Width, nextState.Width)
require.Equal(t, test.expectedState.Height, nextState.Height)
require.Equal(t, test.expectedState.Food, nextState.Food)
require.Equal(t, test.expectedState.Snakes, nextState.Snakes)
}
}
}
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: EliminatedByOutOfHealth,
},
},
Food: []Point{{9, 9}},
},
[]SnakeMove{
{ID: "one", Move: MoveUp},
{ID: "two", Move: MoveDown},
},
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: EliminatedByOutOfHealth,
},
},
Food: []Point{{9, 9}},
},
},
}
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, err, test.expectedError)
if test.expectedState != nil {
require.Equal(t, test.expectedState.Width, nextState.Width)
require.Equal(t, test.expectedState.Height, nextState.Height)
require.Equal(t, test.expectedState.Food, nextState.Food)
require.Equal(t, test.expectedState.Snakes, nextState.Snakes)
}
}
}
func TestMoveSnakes(t *testing.T) {
b := &BoardState{
Snakes: []Snake{
{
ID: "one",
Body: []Point{{10, 110}, {11, 110}},
Health: 111111,
},
{
ID: "two",
Body: []Point{{23, 220}, {22, 220}, {21, 220}, {20, 220}},
Health: 222222,
},
{
ID: "three",
Body: []Point{{0, 0}},
Health: 1,
EliminatedCause: EliminatedByOutOfBounds,
},
},
}
tests := []struct {
MoveOne string
ExpectedOne []Point
MoveTwo string
ExpectedTwo []Point
MoveThree string
ExpectedThree []Point
}{
{
MoveDown, []Point{{10, 109}, {10, 110}},
MoveUp, []Point{{23, 221}, {23, 220}, {22, 220}, {21, 220}},
MoveDown, []Point{{0, 0}},
},
{
MoveRight, []Point{{11, 109}, {10, 109}},
MoveLeft, []Point{{22, 221}, {23, 221}, {23, 220}, {22, 220}},
MoveDown, []Point{{0, 0}},
},
{
MoveRight, []Point{{12, 109}, {11, 109}},
MoveLeft, []Point{{21, 221}, {22, 221}, {23, 221}, {23, 220}},
MoveDown, []Point{{0, 0}},
},
{
MoveRight, []Point{{13, 109}, {12, 109}},
MoveLeft, []Point{{20, 221}, {21, 221}, {22, 221}, {23, 221}},
MoveDown, []Point{{0, 0}},
},
{
MoveDown, []Point{{13, 108}, {13, 109}},
MoveUp, []Point{{20, 222}, {20, 221}, {21, 221}, {22, 221}},
MoveDown, []Point{{0, 0}},
},
}
r := StandardRuleset{}
for _, test := range tests {
moves := []SnakeMove{
{ID: "one", Move: test.MoveOne},
{ID: "two", Move: test.MoveTwo},
{ID: "three", Move: test.MoveThree},
}
err := r.moveSnakes(b, moves)
require.NoError(t, err)
require.Len(t, b.Snakes, 3)
require.Equal(t, int32(111111), b.Snakes[0].Health)
require.Equal(t, int32(222222), b.Snakes[1].Health)
require.Equal(t, int32(1), b.Snakes[2].Health)
require.Len(t, b.Snakes[0].Body, 2)
require.Len(t, b.Snakes[1].Body, 4)
require.Len(t, b.Snakes[2].Body, 1)
require.Equal(t, len(b.Snakes[0].Body), len(test.ExpectedOne))
for i, e := range test.ExpectedOne {
require.Equal(t, e, b.Snakes[0].Body[i])
}
require.Equal(t, len(b.Snakes[1].Body), len(test.ExpectedTwo))
for i, e := range test.ExpectedTwo {
require.Equal(t, e, b.Snakes[1].Body[i])
}
require.Equal(t, len(b.Snakes[2].Body), len(test.ExpectedThree))
for i, e := range test.ExpectedThree {
require.Equal(t, e, b.Snakes[2].Body[i])
}
}
}
func TestMoveSnakesWrongID(t *testing.T) {
b := &BoardState{
Snakes: []Snake{
{
ID: "one",
Body: []Point{{1, 1}},
},
},
}
moves := []SnakeMove{
{
ID: "not found",
Move: MoveUp,
},
}
r := StandardRuleset{}
err := r.moveSnakes(b, moves)
require.Equal(t, ErrorNoMoveFound, err)
}
func TestMoveSnakesNotEnoughMoves(t *testing.T) {
b := &BoardState{
Snakes: []Snake{
{
ID: "one",
Body: []Point{{1, 1}},
},
{
ID: "two",
Body: []Point{{2, 2}},
},
},
}
moves := []SnakeMove{
{
ID: "two",
Move: MoveUp,
},
}
r := StandardRuleset{}
err := r.moveSnakes(b, moves)
require.Equal(t, ErrorNoMoveFound, err)
}
func TestMoveSnakesExtraMovesIgnored(t *testing.T) {
b := &BoardState{
Snakes: []Snake{
{
ID: "one",
Body: []Point{{1, 1}},
},
},
}
moves := []SnakeMove{
{
ID: "one",
Move: MoveDown,
},
{
ID: "two",
Move: MoveLeft,
},
}
r := StandardRuleset{}
err := r.moveSnakes(b, moves)
require.NoError(t, err)
require.Equal(t, []Point{{1, 0}}, b.Snakes[0].Body)
}
func TestMoveSnakesDefault(t *testing.T) {
tests := []struct {
Body []Point
Move string
Expected []Point
}{
{
Body: []Point{{0, 0}},
Move: "invalid",
Expected: []Point{{0, 1}},
},
{
Body: []Point{{5, 5}, {5, 5}},
Move: "",
Expected: []Point{{5, 6}, {5, 5}},
},
{
Body: []Point{{5, 5}, {5, 4}},
Expected: []Point{{5, 6}, {5, 5}},
},
{
Body: []Point{{5, 4}, {5, 5}},
Expected: []Point{{5, 3}, {5, 4}},
},
{
Body: []Point{{5, 4}, {5, 5}},
Expected: []Point{{5, 3}, {5, 4}},
},
{
Body: []Point{{4, 5}, {5, 5}},
Expected: []Point{{3, 5}, {4, 5}},
},
{
Body: []Point{{5, 5}, {4, 5}},
Expected: []Point{{6, 5}, {5, 5}},
},
}
r := StandardRuleset{}
for _, test := range tests {
b := &BoardState{
Snakes: []Snake{
{ID: "one", Body: test.Body},
},
}
moves := []SnakeMove{{ID: "one", Move: test.Move}}
err := r.moveSnakes(b, moves)
require.NoError(t, err)
require.Len(t, b.Snakes, 1)
require.Equal(t, len(test.Body), len(b.Snakes[0].Body))
require.Equal(t, len(test.Expected), len(b.Snakes[0].Body))
for i, e := range test.Expected {
require.Equal(t, e, b.Snakes[0].Body[i])
}
}
}
func TestGetDefaultMove(t *testing.T) {
tests := []struct {
SnakeBody []Point
ExpectedMove string
}{
// Default is always up
{
SnakeBody: []Point{},
ExpectedMove: MoveUp,
},
{
SnakeBody: []Point{{0, 0}},
ExpectedMove: MoveUp,
},
{
SnakeBody: []Point{{-1, -1}},
ExpectedMove: MoveUp,
},
// Stacked (fallback to default)
{
SnakeBody: []Point{{2, 2}, {2, 2}},
ExpectedMove: MoveUp,
},
// Neck next to head
{
SnakeBody: []Point{{2, 2}, {2, 1}},
ExpectedMove: MoveUp,
},
{
SnakeBody: []Point{{2, 2}, {2, 3}},
ExpectedMove: MoveDown,
},
{
SnakeBody: []Point{{2, 2}, {1, 2}},
ExpectedMove: MoveRight,
},
{
SnakeBody: []Point{{2, 2}, {3, 2}},
ExpectedMove: MoveLeft,
},
// Board wrap cases
{
SnakeBody: []Point{{0, 0}, {0, 2}},
ExpectedMove: MoveUp,
},
{
SnakeBody: []Point{{0, 0}, {2, 0}},
ExpectedMove: MoveRight,
},
{
SnakeBody: []Point{{0, 2}, {0, 0}},
ExpectedMove: MoveDown,
},
{
SnakeBody: []Point{{2, 0}, {0, 0}},
ExpectedMove: MoveLeft,
},
}
for _, test := range tests {
actualMove := getDefaultMove(test.SnakeBody)
require.Equal(t, test.ExpectedMove, actualMove)
}
}
func TestReduceSnakeHealth(t *testing.T) {
b := &BoardState{
Snakes: []Snake{
{
Body: []Point{{0, 0}, {0, 1}},
Health: 99,
},
{
Body: []Point{{5, 8}, {6, 8}, {7, 8}},
Health: 2,
},
{
Body: []Point{{0, 0}, {0, 1}},
Health: 50,
EliminatedCause: EliminatedByCollision,
},
},
}
r := StandardRuleset{}
err := r.reduceSnakeHealth(b)
require.NoError(t, err)
require.Equal(t, b.Snakes[0].Health, int32(98))
require.Equal(t, b.Snakes[1].Health, int32(1))
require.Equal(t, b.Snakes[2].Health, int32(50))
err = r.reduceSnakeHealth(b)
require.NoError(t, err)
require.Equal(t, b.Snakes[0].Health, int32(97))
require.Equal(t, b.Snakes[1].Health, int32(0))
require.Equal(t, b.Snakes[2].Health, int32(50))
err = r.reduceSnakeHealth(b)
require.NoError(t, err)
require.Equal(t, b.Snakes[0].Health, int32(96))
require.Equal(t, b.Snakes[1].Health, int32(-1))
require.Equal(t, b.Snakes[2].Health, int32(50))
err = r.reduceSnakeHealth(b)
require.NoError(t, err)
require.Equal(t, b.Snakes[0].Health, int32(95))
require.Equal(t, b.Snakes[1].Health, int32(-2))
require.Equal(t, b.Snakes[2].Health, int32(50))
}
func TestSnakeIsOutOfHealth(t *testing.T) {
tests := []struct {
Health int32
Expected bool
}{
{Health: math.MinInt32, Expected: true},
{Health: -10, Expected: true},
{Health: -2, Expected: true},
{Health: -1, Expected: true},
{Health: 0, Expected: true},
{Health: 1, Expected: false},
{Health: 2, Expected: false},
{Health: 10, Expected: false},
{Health: math.MaxInt32, Expected: false},
}
for _, test := range tests {
s := &Snake{Health: test.Health}
require.Equal(t, test.Expected, snakeIsOutOfHealth(s), "Health: %+v", test.Health)
}
}
func TestSnakeIsOutOfBounds(t *testing.T) {
boardWidth := int32(10)
boardHeight := int32(100)
tests := []struct {
Point Point
Expected bool
}{
{Point{X: math.MinInt32, Y: math.MinInt32}, true},
{Point{X: math.MinInt32, Y: 0}, true},
{Point{X: 0, Y: math.MinInt32}, true},
{Point{X: -1, Y: -1}, true},
{Point{X: -1, Y: 0}, true},
{Point{X: 0, Y: -1}, true},
{Point{X: 0, Y: 0}, false},
{Point{X: 1, Y: 0}, false},
{Point{X: 0, Y: 1}, false},
{Point{X: 1, Y: 1}, false},
{Point{X: 9, Y: 9}, false},
{Point{X: 9, Y: 10}, false},
{Point{X: 9, Y: 11}, false},
{Point{X: 10, Y: 9}, true},
{Point{X: 10, Y: 10}, true},
{Point{X: 10, Y: 11}, true},
{Point{X: 11, Y: 9}, true},
{Point{X: 11, Y: 10}, true},
{Point{X: 11, Y: 11}, true},
{Point{X: math.MaxInt32, Y: 11}, true},
{Point{X: 9, Y: 99}, false},
{Point{X: 9, Y: 100}, true},
{Point{X: 9, Y: 101}, true},
{Point{X: 9, Y: math.MaxInt32}, true},
{Point{X: math.MaxInt32, Y: math.MaxInt32}, true},
}
for _, test := range tests {
// Test with point as head
s := Snake{Body: []Point{test.Point}}
require.Equal(t, test.Expected, snakeIsOutOfBounds(&s, boardWidth, boardHeight), "Head%+v", test.Point)
// Test with point as body
s = Snake{Body: []Point{{0, 0}, {0, 0}, test.Point}}
require.Equal(t, test.Expected, snakeIsOutOfBounds(&s, boardWidth, boardHeight), "Body%+v", test.Point)
}
}
func TestSnakeHasBodyCollidedSelf(t *testing.T) {
tests := []struct {
Body []Point
Expected bool
}{
{[]Point{{1, 1}}, false},
// Self stacks should self collide
// (we rely on snakes moving before we check self-collision on turn one)
{[]Point{{2, 2}, {2, 2}}, true},
{[]Point{{3, 3}, {3, 3}, {3, 3}}, true},
{[]Point{{5, 5}, {5, 5}, {5, 5}, {5, 5}, {5, 5}}, true},
// Non-collision cases
{[]Point{{0, 0}, {1, 0}, {1, 0}}, false},
{[]Point{{0, 0}, {1, 0}, {2, 0}}, false},
{[]Point{{0, 0}, {1, 0}, {2, 0}, {2, 0}, {2, 0}}, false},
{[]Point{{0, 0}, {1, 0}, {2, 0}, {3, 0}, {4, 0}}, false},
{[]Point{{0, 0}, {0, 1}, {0, 2}}, false},
{[]Point{{0, 0}, {0, 1}, {0, 2}, {0, 2}, {0, 2}}, false},
{[]Point{{0, 0}, {0, 1}, {0, 2}, {0, 3}, {0, 4}}, false},
// Collision cases
{[]Point{{0, 0}, {1, 0}, {0, 0}}, true},
{[]Point{{0, 0}, {0, 0}, {1, 0}}, true},
{[]Point{{0, 0}, {1, 0}, {1, 1}, {0, 1}, {0, 0}}, true},
{[]Point{{4, 4}, {3, 4}, {3, 3}, {4, 4}, {4, 4}}, true},
{[]Point{{3, 3}, {3, 4}, {3, 3}, {4, 4}, {4, 5}}, true},
}
for _, test := range tests {
s := Snake{Body: test.Body}
require.Equal(t, test.Expected, snakeHasBodyCollided(&s, &s), "Body%q", s.Body)
}
}
func TestSnakeHasBodyCollidedOther(t *testing.T) {
tests := []struct {
SnakeBody []Point
OtherBody []Point
Expected bool
}{
{
// Just heads
[]Point{{0, 0}},
[]Point{{1, 1}},
false,
},
{
// Head-to-heads are not considered in body collisions
[]Point{{0, 0}},
[]Point{{0, 0}},
false,
},
{
// Stacked bodies
[]Point{{0, 0}},
[]Point{{0, 0}, {0, 0}},
true,
},
{
// Separate stacked bodies
[]Point{{0, 0}, {0, 0}, {0, 0}},
[]Point{{1, 1}, {1, 1}, {1, 1}},
false,
},
{
// Stacked bodies, separated heads
[]Point{{0, 0}, {1, 0}, {1, 0}},
[]Point{{2, 0}, {1, 0}, {1, 0}},
false,
},
{
// Mid-snake collision
[]Point{{1, 1}},
[]Point{{0, 1}, {1, 1}, {2, 1}},
true,
},
}
for _, test := range tests {
s := &Snake{Body: test.SnakeBody}
o := &Snake{Body: test.OtherBody}
require.Equal(t, test.Expected, snakeHasBodyCollided(s, o), "Snake%q Other%q", s.Body, o.Body)
}
}
func TestSnakeHasLostHeadToHead(t *testing.T) {
tests := []struct {
SnakeBody []Point
OtherBody []Point
Expected bool
ExpectedOpposite bool
}{
{
// Just heads
[]Point{{0, 0}},
[]Point{{1, 1}},
false, false,
},
{
// Just heads colliding
[]Point{{0, 0}},
[]Point{{0, 0}},
true, true,
},
{
// One snake larger
[]Point{{0, 0}, {1, 0}, {2, 0}},
[]Point{{0, 0}},
false, true,
},
{
// Other snake equal
[]Point{{0, 0}, {1, 0}, {2, 0}},
[]Point{{0, 0}, {0, 1}, {0, 2}},
true, true,
},
{
// Other snake longer
[]Point{{0, 0}, {1, 0}, {2, 0}},
[]Point{{0, 0}, {0, 1}, {0, 2}, {0, 3}},
true, false,
},
{
// Body collision
[]Point{{0, 1}, {1, 1}, {2, 1}},
[]Point{{0, 0}, {0, 1}, {0, 2}, {0, 3}},
false, false,
},
{
// Separate stacked bodies, head collision
[]Point{{3, 10}, {2, 10}, {2, 10}},
[]Point{{3, 10}, {4, 10}, {4, 10}},
true, true,
},
{
// Separate stacked bodies, head collision
[]Point{{10, 3}, {10, 2}, {10, 1}, {10, 0}},
[]Point{{10, 3}, {10, 4}, {10, 5}},
false, true,
},
}
for _, test := range tests {
s := Snake{Body: test.SnakeBody}
o := Snake{Body: test.OtherBody}
require.Equal(t, test.Expected, snakeHasLostHeadToHead(&s, &o), "Snake%q Other%q", s.Body, o.Body)
require.Equal(t, test.ExpectedOpposite, snakeHasLostHeadToHead(&o, &s), "Snake%q Other%q", s.Body, o.Body)
}
}
func TestMaybeEliminateSnakes(t *testing.T) {
tests := []struct {
Name string
Snakes []Snake
ExpectedEliminatedCauses []string
ExpectedEliminatedBy []string
Err error
}{
{
"Empty",
[]Snake{},
[]string{},
[]string{},
nil,
},
{
"Zero Snake",
[]Snake{
{},
},
[]string{NotEliminated},
[]string{""},
ErrorZeroLengthSnake,
},
{
"Single Starvation",
[]Snake{
{ID: "1", Body: []Point{{1, 1}}},
},
[]string{EliminatedByOutOfHealth},
[]string{""},
nil,
},
{
"Not Eliminated",
[]Snake{
{ID: "1", Health: 1, Body: []Point{{1, 1}}},
},
[]string{NotEliminated},
[]string{""},
nil,
},
{
"Out of Bounds",
[]Snake{
{ID: "1", Health: 1, Body: []Point{{-1, 1}}},
},
[]string{EliminatedByOutOfBounds},
[]string{""},
nil,
},
{
"Self Collision",
[]Snake{
{ID: "1", Health: 1, Body: []Point{{0, 0}, {0, 1}, {0, 0}}},
},
[]string{EliminatedBySelfCollision},
[]string{"1"},
nil,
},
{
"Multiple Separate Deaths",
[]Snake{
{ID: "1", Health: 1, Body: []Point{{0, 0}, {0, 1}, {0, 0}}},
{ID: "2", Health: 1, Body: []Point{{-1, 1}}},
},
[]string{
EliminatedBySelfCollision,
EliminatedByOutOfBounds},
[]string{"1", ""},
nil,
},
{
"Other Collision",
[]Snake{
{ID: "1", Health: 1, Body: []Point{{0, 2}, {0, 3}, {0, 4}}},
{ID: "2", Health: 1, Body: []Point{{0, 0}, {0, 1}, {0, 2}}},
},
[]string{
EliminatedByCollision,
NotEliminated},
[]string{"2", ""},
nil,
},
{
"All Eliminated Head 2 Head",
[]Snake{
{ID: "1", Health: 1, Body: []Point{{1, 1}}},
{ID: "2", Health: 1, Body: []Point{{1, 1}}},
{ID: "3", Health: 1, Body: []Point{{1, 1}}},
},
[]string{
EliminatedByHeadToHeadCollision,
EliminatedByHeadToHeadCollision,
EliminatedByHeadToHeadCollision,
},
[]string{"2", "1", "1"},
nil,
},
{
"One Snake wins Head 2 Head",
[]Snake{
{ID: "1", Health: 1, Body: []Point{{1, 1}, {0, 1}}},
{ID: "2", Health: 1, Body: []Point{{1, 1}, {1, 2}, {1, 3}}},
{ID: "3", Health: 1, Body: []Point{{1, 1}}},
},
[]string{
EliminatedByHeadToHeadCollision,
NotEliminated,
EliminatedByHeadToHeadCollision,
},
[]string{"2", "", "2"},
nil,
},
{
"All Snakes Body Eliminated",
[]Snake{
{ID: "1", Health: 1, Body: []Point{{4, 4}, {3, 3}}},
{ID: "2", Health: 1, Body: []Point{{3, 3}, {2, 2}}},
{ID: "3", Health: 1, Body: []Point{{2, 2}, {1, 1}}},
{ID: "4", Health: 1, Body: []Point{{1, 1}, {4, 4}}},
{ID: "5", Health: 1, Body: []Point{{4, 4}}}, // Body collision takes priority
},
[]string{
EliminatedByCollision,
EliminatedByCollision,
EliminatedByCollision,
EliminatedByCollision,
EliminatedByCollision,
},
[]string{"4", "1", "2", "3", "4"},
nil,
},
{
"All Snakes Eliminated Head 2 Head",
[]Snake{
{ID: "1", Health: 1, Body: []Point{{4, 4}, {4, 5}}},
{ID: "2", Health: 1, Body: []Point{{4, 4}, {4, 3}}},
{ID: "3", Health: 1, Body: []Point{{4, 4}, {5, 4}}},
{ID: "4", Health: 1, Body: []Point{{4, 4}, {3, 4}}},
},
[]string{
EliminatedByHeadToHeadCollision,
EliminatedByHeadToHeadCollision,
EliminatedByHeadToHeadCollision,
EliminatedByHeadToHeadCollision,
},
[]string{"2", "1", "1", "1"},
nil,
},
{
"4 Snakes Head 2 Head",
[]Snake{
{ID: "1", Health: 1, Body: []Point{{4, 4}, {4, 5}}},
{ID: "2", Health: 1, Body: []Point{{4, 4}, {4, 3}}},
{ID: "3", Health: 1, Body: []Point{{4, 4}, {5, 4}, {6, 4}}},
{ID: "4", Health: 1, Body: []Point{{4, 4}, {3, 4}}},
},
[]string{
EliminatedByHeadToHeadCollision,
EliminatedByHeadToHeadCollision,
NotEliminated,
EliminatedByHeadToHeadCollision,
},
[]string{"3", "3", "", "3"},
nil,
},
}
r := StandardRuleset{}
for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
b := &BoardState{
Width: 10,
Height: 10,
Snakes: test.Snakes,
}
err := r.maybeEliminateSnakes(b)
require.Equal(t, test.Err, err)
for i, snake := range b.Snakes {
require.Equal(t, test.ExpectedEliminatedCauses[i], snake.EliminatedCause)
require.Equal(t, test.ExpectedEliminatedBy[i], snake.EliminatedBy)
}
})
}
}
func TestMaybeEliminateSnakesPriority(t *testing.T) {
tests := []struct {
Snakes []Snake
ExpectedEliminatedCauses []string
ExpectedEliminatedBy []string
}{
{
[]Snake{
{ID: "1", Health: 0, Body: []Point{{-1, 0}, {0, 0}, {1, 0}}},
{ID: "2", Health: 1, Body: []Point{{-1, 0}, {0, 0}, {1, 0}}},
{ID: "3", Health: 1, Body: []Point{{1, 0}, {0, 0}, {1, 0}}},
{ID: "4", Health: 1, Body: []Point{{1, 0}, {1, 1}, {1, 2}}},
{ID: "5", Health: 1, Body: []Point{{2, 2}, {2, 1}, {2, 0}}},
{ID: "6", Health: 1, Body: []Point{{2, 2}, {2, 3}, {2, 4}, {2, 5}}},
},
[]string{
EliminatedByOutOfHealth,
EliminatedByOutOfBounds,
EliminatedBySelfCollision,
EliminatedByCollision,
EliminatedByHeadToHeadCollision,
NotEliminated,
},
[]string{"", "", "3", "3", "6", ""},
},
}
r := StandardRuleset{}
for _, test := range tests {
b := &BoardState{Width: 10, Height: 10, Snakes: test.Snakes}
err := r.maybeEliminateSnakes(b)
require.NoError(t, err)
for i, snake := range b.Snakes {
require.Equal(t, test.ExpectedEliminatedCauses[i], snake.EliminatedCause, snake.ID)
require.Equal(t, test.ExpectedEliminatedBy[i], snake.EliminatedBy, snake.ID)
}
}
}
func TestMaybeDamageHazards(t *testing.T) {
tests := []struct {
Snakes []Snake
Hazards []Point
Food []Point
ExpectedEliminatedCauses []string
ExpectedEliminatedByIDs []string
}{
{},
{
Snakes: []Snake{{Body: []Point{{0, 0}}}},
Hazards: []Point{},
ExpectedEliminatedCauses: []string{NotEliminated},
ExpectedEliminatedByIDs: []string{""},
},
{
Snakes: []Snake{{Body: []Point{{0, 0}}}},
Hazards: []Point{{0, 0}},
ExpectedEliminatedCauses: []string{EliminatedByOutOfHealth},
ExpectedEliminatedByIDs: []string{""},
},
{
Snakes: []Snake{{Body: []Point{{0, 0}}}},
Hazards: []Point{{0, 0}},
Food: []Point{{0, 0}},
ExpectedEliminatedCauses: []string{NotEliminated},
ExpectedEliminatedByIDs: []string{""},
},
{
Snakes: []Snake{{Body: []Point{{0, 0}, {1, 0}, {2, 0}}}},
Hazards: []Point{{1, 0}, {2, 0}},
ExpectedEliminatedCauses: []string{NotEliminated},
ExpectedEliminatedByIDs: []string{""},
},
{
Snakes: []Snake{
{Body: []Point{{0, 0}, {1, 0}, {2, 0}}},
{Body: []Point{{3, 3}, {3, 4}, {3, 5}, {3, 6}}},
},
Hazards: []Point{{1, 0}, {2, 0}, {3, 4}, {3, 5}, {3, 6}},
ExpectedEliminatedCauses: []string{NotEliminated, NotEliminated},
ExpectedEliminatedByIDs: []string{"", ""},
},
{
Snakes: []Snake{
{Body: []Point{{0, 0}, {1, 0}, {2, 0}}},
{Body: []Point{{3, 3}, {3, 4}, {3, 5}, {3, 6}}},
},
Hazards: []Point{{3, 3}},
ExpectedEliminatedCauses: []string{NotEliminated, EliminatedByOutOfHealth},
ExpectedEliminatedByIDs: []string{"", ""},
},
}
for _, test := range tests {
b := &BoardState{Snakes: test.Snakes, Hazards: test.Hazards, Food: test.Food}
r := StandardRuleset{HazardDamagePerTurn: 100}
err := r.maybeDamageHazards(b)
require.NoError(t, err)
for i, snake := range b.Snakes {
require.Equal(t, test.ExpectedEliminatedCauses[i], snake.EliminatedCause)
}
}
}
func TestHazardDamagePerTurn(t *testing.T) {
tests := []struct {
Health int32
HazardDamagePerTurn int32
Food bool
ExpectedHealth int32
ExpectedEliminationCause string
Error error
}{
{100, 1, false, 99, NotEliminated, nil},
{100, 1, true, 100, NotEliminated, nil},
{100, 99, false, 1, NotEliminated, nil},
{100, 99, true, 100, NotEliminated, nil},
{100, 100, false, 0, EliminatedByOutOfHealth, nil},
{100, 101, false, 0, EliminatedByOutOfHealth, nil},
{100, 999, false, 0, EliminatedByOutOfHealth, nil},
{100, 100, true, 100, NotEliminated, nil},
{2, 1, false, 1, NotEliminated, nil},
{1, 1, false, 0, EliminatedByOutOfHealth, nil},
{1, 999, false, 0, EliminatedByOutOfHealth, nil},
{0, 1, false, 0, EliminatedByOutOfHealth, nil},
{0, 999, false, 0, EliminatedByOutOfHealth, nil},
}
for _, test := range tests {
b := &BoardState{Snakes: []Snake{{Health: test.Health, Body: []Point{{0, 0}}}}, Hazards: []Point{{0, 0}}}
if test.Food {
b.Food = []Point{{0, 0}}
}
r := StandardRuleset{HazardDamagePerTurn: test.HazardDamagePerTurn}
err := r.maybeDamageHazards(b)
require.Equal(t, test.Error, err)
require.Equal(t, test.ExpectedHealth, b.Snakes[0].Health)
require.Equal(t, test.ExpectedEliminationCause, b.Snakes[0].EliminatedCause)
}
}
func TestMaybeFeedSnakes(t *testing.T) {
tests := []struct {
Name string
Snakes []Snake
Food []Point
ExpectedSnakes []Snake
ExpectedFood []Point
}{
{
Name: "snake not on food",
Snakes: []Snake{
{Health: 5, Body: []Point{{0, 0}, {0, 1}, {0, 2}}},
},
Food: []Point{{3, 3}},
ExpectedSnakes: []Snake{
{Health: 5, Body: []Point{{0, 0}, {0, 1}, {0, 2}}},
},
ExpectedFood: []Point{{3, 3}},
},
{
Name: "snake on food",
Snakes: []Snake{
{Health: SnakeMaxHealth - 1, Body: []Point{{2, 1}, {1, 1}, {1, 2}, {2, 2}}},
},
Food: []Point{{2, 1}},
ExpectedSnakes: []Snake{
{Health: SnakeMaxHealth, Body: []Point{{2, 1}, {1, 1}, {1, 2}, {2, 2}, {2, 2}}},
},
ExpectedFood: []Point{},
},
{
Name: "food under body",
Snakes: []Snake{
{Body: []Point{{0, 0}, {0, 1}, {0, 2}}},
},
Food: []Point{{0, 1}},
ExpectedSnakes: []Snake{
{Body: []Point{{0, 0}, {0, 1}, {0, 2}}},
},
ExpectedFood: []Point{{0, 1}},
},
{
Name: "snake on food but already eliminated",
Snakes: []Snake{
{Body: []Point{{0, 0}, {0, 1}, {0, 2}}, EliminatedCause: "EliminatedByOutOfBounds"},
},
Food: []Point{{0, 0}},
ExpectedSnakes: []Snake{
{Body: []Point{{0, 0}, {0, 1}, {0, 2}}},
},
ExpectedFood: []Point{{0, 0}},
},
{
Name: "multiple snakes on same food",
Snakes: []Snake{
{Health: SnakeMaxHealth, Body: []Point{{0, 0}, {0, 1}, {0, 2}}},
{Health: SnakeMaxHealth - 9, Body: []Point{{0, 0}, {1, 0}, {2, 0}}},
},
Food: []Point{{0, 0}, {4, 4}},
ExpectedSnakes: []Snake{
{Health: SnakeMaxHealth, Body: []Point{{0, 0}, {0, 1}, {0, 2}, {0, 2}}},
{Health: SnakeMaxHealth, Body: []Point{{0, 0}, {1, 0}, {2, 0}, {2, 0}}},
},
ExpectedFood: []Point{{4, 4}},
},
}
r := StandardRuleset{}
for _, test := range tests {
b := &BoardState{
Snakes: test.Snakes,
Food: test.Food,
}
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++ {
require.Equal(t, test.ExpectedSnakes[i].Health, b.Snakes[i].Health, test.Name)
require.Equal(t, test.ExpectedSnakes[i].Body, b.Snakes[i].Body, test.Name)
}
require.Equal(t, test.ExpectedFood, b.Food, test.Name)
}
}
func TestMaybeSpawnFoodMinimum(t *testing.T) {
tests := []struct {
MinimumFood int32
Food []Point
ExpectedFood int
}{
// Use pre-tested seeds and results
{0, []Point{}, 0},
{1, []Point{}, 1},
{9, []Point{}, 9},
{7, []Point{{4, 5}, {4, 4}, {4, 1}}, 7},
}
for _, test := range tests {
r := StandardRuleset{MinimumFood: test.MinimumFood}
b := &BoardState{
Height: 11,
Width: 11,
Snakes: []Snake{
{Body: []Point{{1, 0}, {1, 1}}},
{Body: []Point{{0, 1}, {0, 2}, {0, 3}}},
},
Food: test.Food,
}
err := r.maybeSpawnFood(b)
require.NoError(t, err)
require.Equal(t, test.ExpectedFood, len(b.Food))
}
}
func TestMaybeSpawnFoodZeroChance(t *testing.T) {
r := StandardRuleset{FoodSpawnChance: 0}
b := &BoardState{
Height: 11,
Width: 11,
Snakes: []Snake{
{Body: []Point{{1, 0}, {1, 1}}},
{Body: []Point{{0, 1}, {0, 2}, {0, 3}}},
},
Food: []Point{},
}
for i := 0; i < 1000; i++ {
err := r.maybeSpawnFood(b)
require.NoError(t, err)
require.Equal(t, len(b.Food), 0)
}
}
func TestMaybeSpawnFoodHundredChance(t *testing.T) {
r := StandardRuleset{FoodSpawnChance: 100}
b := &BoardState{
Height: 11,
Width: 11,
Snakes: []Snake{
{Body: []Point{{1, 0}, {1, 1}}},
{Body: []Point{{0, 1}, {0, 2}, {0, 3}}},
},
Food: []Point{},
}
for i := 1; i <= 22; i++ {
err := r.maybeSpawnFood(b)
require.NoError(t, err)
require.Equal(t, i, len(b.Food))
}
}
func TestMaybeSpawnFoodHalfChance(t *testing.T) {
tests := []struct {
Seed int64
Food []Point
ExpectedFood int32
}{
// Use pre-tested seeds and results
{123, []Point{}, 1},
{12345, []Point{}, 0},
{456, []Point{{4, 4}}, 1},
{789, []Point{{4, 4}}, 2},
{511, []Point{{4, 4}}, 1},
{165, []Point{{4, 4}}, 2},
}
r := StandardRuleset{FoodSpawnChance: 50}
for _, test := range tests {
b := &BoardState{
Height: 4,
Width: 5,
Snakes: []Snake{
{Body: []Point{{1, 0}, {1, 1}}},
{Body: []Point{{0, 1}, {0, 2}, {0, 3}}},
},
Food: test.Food,
}
rand.Seed(test.Seed)
err := r.maybeSpawnFood(b)
require.NoError(t, err)
require.Equal(t, test.ExpectedFood, int32(len(b.Food)), "Seed %d", test.Seed)
}
}
func TestIsGameOver(t *testing.T) {
tests := []struct {
Snakes []Snake
Expected bool
}{
{[]Snake{}, true},
{[]Snake{{}}, true},
{[]Snake{{}, {}}, false},
{[]Snake{{}, {}, {}, {}, {}}, false},
{
[]Snake{
{EliminatedCause: EliminatedByCollision},
{EliminatedCause: NotEliminated},
},
true,
},
{
[]Snake{
{EliminatedCause: NotEliminated},
{EliminatedCause: EliminatedByCollision},
{EliminatedCause: NotEliminated},
{EliminatedCause: NotEliminated},
},
false,
},
{
[]Snake{
{EliminatedCause: EliminatedByOutOfBounds},
{EliminatedCause: EliminatedByOutOfBounds},
{EliminatedCause: EliminatedByOutOfBounds},
{EliminatedCause: EliminatedByOutOfBounds},
},
true,
},
{
[]Snake{
{EliminatedCause: EliminatedByOutOfBounds},
{EliminatedCause: EliminatedByOutOfBounds},
{EliminatedCause: EliminatedByOutOfBounds},
{EliminatedCause: NotEliminated},
},
true,
},
{
[]Snake{
{EliminatedCause: EliminatedByOutOfBounds},
{EliminatedCause: EliminatedByOutOfBounds},
{EliminatedCause: NotEliminated},
{EliminatedCause: NotEliminated},
},
false,
},
}
r := StandardRuleset{}
for _, test := range tests {
b := &BoardState{
Height: 11,
Width: 11,
Snakes: test.Snakes,
Food: []Point{},
}
actual, err := r.IsGameOver(b)
require.NoError(t, err)
require.Equal(t, test.Expected, actual)
}
}