* adding testing of output file and write line for /end * fix regression in latency rounding for board
667 lines
17 KiB
Go
667 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.BoardState{
|
|
Height: 11,
|
|
Width: 11,
|
|
Snakes: []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.BoardState{
|
|
Height: 11,
|
|
Width: 11,
|
|
Snakes: []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.BoardState{
|
|
Turn: 99,
|
|
Height: 19,
|
|
Width: 25,
|
|
Food: []rules.Point{{X: 9, Y: 4}},
|
|
Snakes: []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",
|
|
},
|
|
},
|
|
Hazards: []rules.Point{{X: 8, Y: 6}},
|
|
},
|
|
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.BoardState{
|
|
Height: 19,
|
|
Width: 25,
|
|
Snakes: []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",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
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.BoardState{
|
|
Height: 11,
|
|
Width: 11,
|
|
Snakes: []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.BoardState{
|
|
Height: 11,
|
|
Width: 11,
|
|
Snakes: []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}
|
|
|
|
nextBoardState := gameState.createNextBoardState(boardState)
|
|
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.Settings{
|
|
FoodSpawnChance: 1,
|
|
MinimumFood: 2,
|
|
HazardDamagePerTurn: 3,
|
|
RoyaleSettings: rules.RoyaleSettings{
|
|
ShrinkEveryNTurns: 4,
|
|
},
|
|
}}
|
|
|
|
gameState.Run()
|
|
|
|
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) ModifyInitialBoardState(initialState *rules.BoardState) (*rules.BoardState, error) {
|
|
return initialState, nil
|
|
}
|
|
func (ruleset StubRuleset) CreateNextBoardState(prevState *rules.BoardState, moves []rules.SnakeMove) (*rules.BoardState, error) {
|
|
return prevState, nil
|
|
}
|
|
func (ruleset StubRuleset) IsGameOver(state *rules.BoardState) (bool, error) {
|
|
return state.Turn >= ruleset.maxTurns, 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)
|
|
}
|