Byte-snake-engine/cli/commands/play_test.go
Rob O'Dwyer 82e1999126
DEV-1761: New rules API (#118)
* DEV-1761: Clean up Ruleset interface (#115)

* remove legacy ruleset types and simplify ruleset interface

* remove unnecessary settings argument from Ruleset interface

* decouple rules.Settings from client API and store settings as strings

* DEV 1761: Add new BoardState and Point fields (#117)

* add Point.TTL, Point.Value, GameState and PointState to BoardState

* allow maps to access BoardState.GameState,PointState

* add PreUpdateBoard and refactor snail_mode with it

* fix bug where an extra turn was printed to the console

* fix formatting

* fix lint errors

Co-authored-by: JonathanArns <jonathan.arns@googlemail.com>
2022-10-28 16:49:49 -07:00

649 lines
17 KiB
Go

package commands
import (
"bytes"
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"strings"
"testing"
"time"
"github.com/BattlesnakeOfficial/rules"
"github.com/BattlesnakeOfficial/rules/board"
"github.com/BattlesnakeOfficial/rules/client"
"github.com/BattlesnakeOfficial/rules/test"
"github.com/stretchr/testify/require"
)
func buildDefaultGameState() *GameState {
gameState := &GameState{
Width: 11,
Height: 11,
Names: nil,
Timeout: 500,
Sequential: false,
GameType: "standard",
MapName: "standard",
ViewMap: false,
UseColor: false,
Seed: 1,
TurnDelay: 0,
TurnDuration: 0,
OutputPath: "",
FoodSpawnChance: 15,
MinimumFood: 1,
HazardDamagePerTurn: 14,
ShrinkEveryNTurns: 25,
}
return gameState
}
func TestGetIndividualBoardStateForSnake(t *testing.T) {
s1 := rules.Snake{ID: "one", Body: []rules.Point{{X: 3, Y: 3}}}
s2 := rules.Snake{ID: "two", Body: []rules.Point{{X: 4, Y: 3}}}
state := rules.NewBoardState(11, 11).
WithSnakes(
[]rules.Snake{s1, s2},
)
s1State := SnakeState{
ID: "one",
Name: "ONE",
URL: "http://example1.com",
Head: "safe",
Tail: "curled",
Color: "#123456",
}
s2State := SnakeState{
ID: "two",
Name: "TWO",
URL: "http://example2.com",
Head: "silly",
Tail: "bolt",
Color: "#654321",
}
gameState := buildDefaultGameState()
err := gameState.Initialize()
require.NoError(t, err)
gameState.gameID = "GAME_ID"
gameState.snakeStates = map[string]SnakeState{
s1State.ID: s1State,
s2State.ID: s2State,
}
snakeRequest := gameState.getRequestBodyForSnake(state, s1State)
requestBody := serialiseSnakeRequest(snakeRequest)
test.RequireJSONMatchesFixture(t, "testdata/snake_request_body.json", string(requestBody))
}
func TestSettingsRequestSerialization(t *testing.T) {
s1 := rules.Snake{ID: "one", Body: []rules.Point{{X: 3, Y: 3}}}
s2 := rules.Snake{ID: "two", Body: []rules.Point{{X: 4, Y: 3}}}
state := rules.NewBoardState(11, 11).
WithSnakes([]rules.Snake{s1, s2})
s1State := SnakeState{
ID: "one",
Name: "ONE",
URL: "http://example1.com",
Head: "safe",
Tail: "curled",
Color: "#123456",
}
s2State := SnakeState{
ID: "two",
Name: "TWO",
URL: "http://example2.com",
Head: "silly",
Tail: "bolt",
Color: "#654321",
}
for _, gt := range []string{
rules.GameTypeStandard, rules.GameTypeRoyale, rules.GameTypeSolo,
rules.GameTypeWrapped, rules.GameTypeConstrictor,
} {
t.Run(gt, func(t *testing.T) {
gameState := buildDefaultGameState()
gameState.FoodSpawnChance = 11
gameState.MinimumFood = 7
gameState.HazardDamagePerTurn = 19
gameState.ShrinkEveryNTurns = 17
gameState.GameType = gt
err := gameState.Initialize()
require.NoError(t, err)
gameState.gameID = "GAME_ID"
gameState.snakeStates = map[string]SnakeState{s1State.ID: s1State, s2State.ID: s2State}
snakeRequest := gameState.getRequestBodyForSnake(state, s1State)
requestBody := serialiseSnakeRequest(snakeRequest)
t.Log(string(requestBody))
test.RequireJSONMatchesFixture(t, fmt.Sprintf("testdata/snake_request_body_%s.json", gt), string(requestBody))
})
}
}
func TestConvertRulesSnakes(t *testing.T) {
tests := []struct {
name string
snakes []rules.Snake
state map[string]SnakeState
expected []client.Snake
}{
{
name: "empty",
snakes: []rules.Snake{},
state: map[string]SnakeState{},
expected: []client.Snake{},
},
{
name: "all properties",
snakes: []rules.Snake{
{ID: "one", Body: []rules.Point{{X: 3, Y: 3}, {X: 2, Y: 3}}, Health: 100},
},
state: map[string]SnakeState{
"one": {
ID: "one",
Name: "ONE",
URL: "http://example1.com",
Head: "a",
Tail: "b",
Color: "#012345",
LastMove: "up",
Character: '+',
Latency: time.Millisecond * 42,
},
},
expected: []client.Snake{
{
ID: "one",
Name: "ONE",
Latency: "42",
Health: 100,
Body: []client.Coord{{X: 3, Y: 3}, {X: 2, Y: 3}},
Head: client.Coord{X: 3, Y: 3},
Length: 2,
Shout: "",
Customizations: client.Customizations{
Color: "#012345",
Head: "a",
Tail: "b",
},
},
},
},
{
name: "some eliminated",
snakes: []rules.Snake{
{
ID: "one",
EliminatedCause: rules.EliminatedByCollision,
EliminatedOnTurn: 1,
Body: []rules.Point{{X: 3, Y: 3}},
},
{ID: "two", Body: []rules.Point{{X: 4, Y: 3}}},
},
state: map[string]SnakeState{
"one": {ID: "one"},
"two": {ID: "two"},
},
expected: []client.Snake{
{
ID: "two",
Latency: "0",
Body: []client.Coord{{X: 4, Y: 3}},
Head: client.Coord{X: 4, Y: 3},
Length: 1,
},
},
},
{
name: "all eliminated",
snakes: []rules.Snake{
{
ID: "one",
EliminatedCause: rules.EliminatedByCollision,
EliminatedOnTurn: 1,
Body: []rules.Point{{X: 3, Y: 3}},
},
},
state: map[string]SnakeState{
"one": {ID: "one"},
},
expected: []client.Snake{},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actual := convertRulesSnakes(test.snakes, test.state)
require.Equal(t, test.expected, actual)
})
}
}
func TestBuildFrameEvent(t *testing.T) {
tests := []struct {
name string
boardState *rules.BoardState
snakeStates map[string]SnakeState
expected board.GameEvent
}{
{
name: "empty",
boardState: rules.NewBoardState(11, 11),
snakeStates: map[string]SnakeState{},
expected: board.GameEvent{
EventType: board.EVENT_TYPE_FRAME,
Data: board.GameFrame{
Turn: 0,
Snakes: []board.Snake{},
Food: []rules.Point{},
Hazards: []rules.Point{},
},
},
},
{
name: "snake fields",
boardState: rules.NewBoardState(19, 25).
WithTurn(99).
WithFood([]rules.Point{{X: 9, Y: 4}}).
WithHazards([]rules.Point{{X: 8, Y: 6}}).
WithSnakes([]rules.Snake{
{
ID: "1",
Body: []rules.Point{
{X: 9, Y: 4},
{X: 8, Y: 4},
{X: 7, Y: 4},
},
Health: 97,
EliminatedCause: rules.EliminatedBySelfCollision,
EliminatedOnTurn: 45,
EliminatedBy: "1",
},
}),
snakeStates: map[string]SnakeState{
"1": {
URL: "http://example.com",
Name: "One",
ID: "1",
LastMove: "left",
Color: "#ff00ff",
Head: "silly",
Tail: "default",
Author: "AUTHOR",
Version: "1.5",
Error: nil,
StatusCode: 200,
Latency: 54 * time.Millisecond,
},
},
expected: board.GameEvent{
EventType: board.EVENT_TYPE_FRAME,
Data: board.GameFrame{
Turn: 99,
Snakes: []board.Snake{
{
ID: "1",
Name: "One",
Body: []rules.Point{{X: 9, Y: 4}, {X: 8, Y: 4}, {X: 7, Y: 4}},
Health: 97,
Death: &board.Death{
Cause: rules.EliminatedBySelfCollision,
Turn: 45,
EliminatedBy: "1",
},
Color: "#ff00ff",
HeadType: "silly",
TailType: "default",
Latency: "54",
Author: "AUTHOR",
StatusCode: 200,
Error: "",
IsBot: false,
IsEnvironment: false,
},
},
Food: []rules.Point{{X: 9, Y: 4}},
Hazards: []rules.Point{{X: 8, Y: 6}},
},
},
},
{
name: "snake errors",
boardState: rules.NewBoardState(19, 25).
WithSnakes([]rules.Snake{
{
ID: "bad_status",
},
{
ID: "connection_error",
},
}),
snakeStates: map[string]SnakeState{
"bad_status": {
StatusCode: 504,
Latency: 54 * time.Millisecond,
},
"connection_error": {
Error: fmt.Errorf("error connecting to host"),
Latency: 0,
},
},
expected: board.GameEvent{
EventType: board.EVENT_TYPE_FRAME,
Data: board.GameFrame{
Snakes: []board.Snake{
{
ID: "bad_status",
Latency: "54",
StatusCode: 504,
Error: "7:Bad HTTP status code 504",
},
{
ID: "connection_error",
Latency: "1",
StatusCode: 0,
Error: "0:Error communicating with server",
},
},
Food: []rules.Point{},
Hazards: []rules.Point{},
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
gameState := GameState{
snakeStates: test.snakeStates,
}
actual := gameState.buildFrameEvent(test.boardState)
require.Equalf(t, test.expected, actual, "%#v", actual)
})
}
}
func TestGetMoveForSnake(t *testing.T) {
s1 := rules.Snake{ID: "one", Body: []rules.Point{{X: 3, Y: 3}}}
s2 := rules.Snake{ID: "two", Body: []rules.Point{{X: 4, Y: 3}}}
boardState := rules.NewBoardState(11, 11).WithSnakes([]rules.Snake{s1, s2})
tests := []struct {
name string
boardState *rules.BoardState
snakeState SnakeState
responseErr error
responseCode int
responseBody string
responseLatency time.Duration
expectedSnakeState SnakeState
}{
{
name: "invalid URL",
boardState: boardState,
snakeState: SnakeState{
ID: "one",
URL: "",
LastMove: rules.MoveLeft,
},
expectedSnakeState: SnakeState{
ID: "one",
URL: "",
LastMove: rules.MoveLeft,
Error: errors.New(`parse "": empty url`),
},
},
{
name: "error response",
boardState: boardState,
snakeState: SnakeState{
ID: "one",
URL: "http://example.com",
LastMove: rules.MoveLeft,
},
responseErr: errors.New("connection error"),
expectedSnakeState: SnakeState{
ID: "one",
URL: "http://example.com",
LastMove: rules.MoveLeft,
Error: errors.New("connection error"),
},
},
{
name: "bad response body",
boardState: boardState,
snakeState: SnakeState{
ID: "one",
URL: "http://example.com",
LastMove: rules.MoveLeft,
},
responseCode: 200,
responseBody: `right`,
responseLatency: 54 * time.Millisecond,
expectedSnakeState: SnakeState{
ID: "one",
URL: "http://example.com",
LastMove: rules.MoveLeft,
Error: errors.New("invalid character 'r' looking for beginning of value"),
StatusCode: 200,
Latency: 54 * time.Millisecond,
},
},
{
name: "bad move value",
boardState: boardState,
snakeState: SnakeState{
ID: "one",
URL: "http://example.com",
LastMove: rules.MoveLeft,
},
responseCode: 200,
responseBody: `{"move": "north"}`,
responseLatency: 54 * time.Millisecond,
expectedSnakeState: SnakeState{
ID: "one",
URL: "http://example.com",
LastMove: rules.MoveLeft,
StatusCode: 200,
Latency: 54 * time.Millisecond,
},
},
{
name: "bad status code",
boardState: boardState,
snakeState: SnakeState{
ID: "one",
URL: "http://example.com",
LastMove: rules.MoveLeft,
},
responseCode: 500,
responseLatency: 54 * time.Millisecond,
expectedSnakeState: SnakeState{
ID: "one",
URL: "http://example.com",
LastMove: rules.MoveLeft,
StatusCode: 500,
Latency: 54 * time.Millisecond,
},
},
{
name: "successful move",
boardState: boardState,
snakeState: SnakeState{
ID: "one",
URL: "http://example.com",
},
responseCode: 200,
responseBody: `{"move": "right"}`,
responseLatency: 54 * time.Millisecond,
expectedSnakeState: SnakeState{
ID: "one",
URL: "http://example.com",
LastMove: rules.MoveRight,
StatusCode: 200,
Latency: 54 * time.Millisecond,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
gameState := buildDefaultGameState()
err := gameState.Initialize()
require.NoError(t, err)
gameState.snakeStates = map[string]SnakeState{test.snakeState.ID: test.snakeState}
gameState.httpClient = stubHTTPClient{test.responseErr, test.responseCode, func(_ string) string { return test.responseBody }, test.responseLatency}
nextSnakeState := gameState.getSnakeUpdate(test.boardState, test.snakeState)
if test.expectedSnakeState.Error != nil {
require.EqualError(t, nextSnakeState.Error, test.expectedSnakeState.Error.Error())
} else {
require.NoError(t, nextSnakeState.Error)
}
nextSnakeState.Error = test.expectedSnakeState.Error
require.Equal(t, test.expectedSnakeState, nextSnakeState)
})
}
}
func TestCreateNextBoardState(t *testing.T) {
s1 := rules.Snake{ID: "one", Body: []rules.Point{{X: 3, Y: 3}}}
boardState := rules.NewBoardState(11, 11).WithSnakes([]rules.Snake{s1})
snakeState := SnakeState{
ID: s1.ID,
URL: "http://example.com",
}
for _, sequential := range []bool{false, true} {
t.Run(fmt.Sprintf("sequential_%v", sequential), func(t *testing.T) {
gameState := buildDefaultGameState()
gameState.Sequential = sequential
err := gameState.Initialize()
require.NoError(t, err)
gameState.snakeStates = map[string]SnakeState{s1.ID: snakeState}
gameState.httpClient = stubHTTPClient{nil, 200, func(_ string) string { return `{"move": "right"}` }, 54 * time.Millisecond}
gameOver, nextBoardState, err := gameState.createNextBoardState(boardState)
require.NoError(t, err)
require.False(t, gameOver)
snakeState = gameState.snakeStates[s1.ID]
require.NotNil(t, nextBoardState)
require.Equal(t, nextBoardState.Turn, 1)
require.Equal(t, nextBoardState.Snakes[0].Body[0], rules.Point{X: 4, Y: 3})
require.Equal(t, snakeState.LastMove, rules.MoveRight)
require.Equal(t, snakeState.StatusCode, 200)
require.Equal(t, snakeState.Latency, 54*time.Millisecond)
})
}
}
func TestOutputFile(t *testing.T) {
gameState := buildDefaultGameState()
gameState.Names = []string{"example snake"}
gameState.URLs = []string{"http://example.com"}
err := gameState.Initialize()
require.NoError(t, err)
gameState.gameID = "GAME_ID"
gameState.idGenerator = func(index int) string { return fmt.Sprintf("snk_%d", index) }
gameState.httpClient = stubHTTPClient{nil, http.StatusOK, func(url string) string {
switch url {
case "http://example.com":
return `
{
"apiversion": "1",
"author": "author",
"color": "#123456",
"head": "safe",
"tail": "curled",
"version": "0.0.1-beta"
}
`
case "http://example.com/move":
return `{"move": "left"}`
}
return ""
}, time.Millisecond * 42}
outputFile := new(closableBuffer)
gameState.outputFile = outputFile
gameState.ruleset = StubRuleset{
maxTurns: 1,
settings: rules.NewSettings(map[string]string{
rules.ParamFoodSpawnChance: "1",
rules.ParamMinimumFood: "2",
rules.ParamHazardDamagePerTurn: "3",
rules.ParamShrinkEveryNTurns: "4",
}),
}
err = gameState.Run()
require.NoError(t, err)
lines := strings.Split(outputFile.String(), "\n")
require.Len(t, lines, 5)
test.RequireJSONMatchesFixture(t, "testdata/jsonl_game.json", lines[0])
test.RequireJSONMatchesFixture(t, "testdata/jsonl_turn_0.json", lines[1])
test.RequireJSONMatchesFixture(t, "testdata/jsonl_turn_1.json", lines[2])
test.RequireJSONMatchesFixture(t, "testdata/jsonl_game_complete.json", lines[3])
require.Equal(t, "", lines[4])
}
type closableBuffer struct {
bytes.Buffer
}
func (closableBuffer) Close() error { return nil }
type StubRuleset struct {
maxTurns int
settings rules.Settings
}
func (ruleset StubRuleset) Name() string { return "standard" }
func (ruleset StubRuleset) Settings() rules.Settings { return ruleset.settings }
func (ruleset StubRuleset) Execute(prevState *rules.BoardState, moves []rules.SnakeMove) (bool, *rules.BoardState, error) {
return prevState.Turn >= ruleset.maxTurns, prevState, nil
}
type stubHTTPClient struct {
err error
statusCode int
body func(url string) string
latency time.Duration
}
func (client stubHTTPClient) request(url string) (*http.Response, time.Duration, error) {
if client.err != nil {
return nil, client.latency, client.err
}
body := ioutil.NopCloser(bytes.NewBufferString(client.body(url)))
response := &http.Response{
Header: make(http.Header),
Body: body,
StatusCode: client.statusCode,
}
return response, client.latency, nil
}
func (client stubHTTPClient) Get(url string) (*http.Response, time.Duration, error) {
return client.request(url)
}
func (client stubHTTPClient) Post(url string, contentType string, body io.Reader) (*http.Response, time.Duration, error) {
return client.request(url)
}