DEV 1916: Fix output issues in CLI (#114)

* adding testing of output file and write line for /end

* fix regression in latency rounding for board
This commit is contained in:
Rob O'Dwyer 2022-10-20 13:16:52 -07:00 committed by GitHub
parent 5f60ccbba8
commit 639362ef46
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 396 additions and 49 deletions

View file

@ -3,9 +3,7 @@ package commands
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "io"
log "github.com/spf13/jwalterweatherman"
"github.com/BattlesnakeOfficial/rules/client" "github.com/BattlesnakeOfficial/rules/client"
) )
@ -23,37 +21,20 @@ type result struct {
IsDraw bool `json:"isDraw"` IsDraw bool `json:"isDraw"`
} }
func (ge *GameExporter) FlushToFile(filepath string, format string) error { func (ge *GameExporter) FlushToFile(outputFile io.Writer) (int, error) {
var formattedOutput []string formattedOutput, err := ge.ConvertToJSON()
var formattingErr error
// TODO: Support more formats
if format == "JSONL" {
formattedOutput, formattingErr = ge.ConvertToJSON()
} else {
log.ERROR.Fatalf("Invalid output format passed: %s", format)
}
if formattingErr != nil {
return formattingErr
}
f, err := os.OpenFile(filepath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil { if err != nil {
return err return 0, err
} }
defer f.Close()
for _, line := range formattedOutput { for _, line := range formattedOutput {
_, err := f.WriteString(fmt.Sprintf("%s\n", line)) _, err := io.WriteString(outputFile, fmt.Sprintf("%s\n", line))
if err != nil { if err != nil {
return err return 0, err
} }
} }
log.DEBUG.Printf("Written %d lines of output to file: %s\n", len(formattedOutput), filepath) return len(formattedOutput), nil
return nil
} }
func (ge *GameExporter) ConvertToJSON() ([]string, error) { func (ge *GameExporter) ConvertToJSON() ([]string, error) {

View file

@ -4,10 +4,12 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"io/ioutil" "io/ioutil"
"math/rand" "math/rand"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path" "path"
"strconv" "strconv"
"strings" "strings"
@ -56,7 +58,7 @@ type GameState struct {
UseColor bool UseColor bool
Seed int64 Seed int64
TurnDelay int TurnDelay int
Output string OutputPath string
ViewInBrowser bool ViewInBrowser bool
BoardURL string BoardURL string
FoodSpawnChance int FoodSpawnChance int
@ -71,6 +73,8 @@ type GameState struct {
httpClient TimedHttpClient httpClient TimedHttpClient
ruleset rules.Ruleset ruleset rules.Ruleset
gameMap maps.GameMap gameMap maps.GameMap
outputFile io.WriteCloser
idGenerator func(int) string
} }
func NewPlayCommand() *cobra.Command { func NewPlayCommand() *cobra.Command {
@ -81,6 +85,9 @@ func NewPlayCommand() *cobra.Command {
Short: "Play a game of Battlesnake locally.", Short: "Play a game of Battlesnake locally.",
Long: "Play a game of Battlesnake locally.", Long: "Play a game of Battlesnake locally.",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
if err := gameState.Initialize(); err != nil {
log.ERROR.Fatalf("Error initializing game: %v", err)
}
gameState.Run() gameState.Run()
}, },
} }
@ -98,7 +105,7 @@ func NewPlayCommand() *cobra.Command {
playCmd.Flags().Int64VarP(&gameState.Seed, "seed", "r", time.Now().UTC().UnixNano(), "Random Seed") playCmd.Flags().Int64VarP(&gameState.Seed, "seed", "r", time.Now().UTC().UnixNano(), "Random Seed")
playCmd.Flags().IntVarP(&gameState.TurnDelay, "delay", "d", 0, "Turn Delay in Milliseconds") playCmd.Flags().IntVarP(&gameState.TurnDelay, "delay", "d", 0, "Turn Delay in Milliseconds")
playCmd.Flags().IntVarP(&gameState.TurnDuration, "duration", "D", 0, "Minimum Turn Duration in Milliseconds") playCmd.Flags().IntVarP(&gameState.TurnDuration, "duration", "D", 0, "Minimum Turn Duration in Milliseconds")
playCmd.Flags().StringVarP(&gameState.Output, "output", "o", "", "File path to output game state to. Existing files will be overwritten") playCmd.Flags().StringVarP(&gameState.OutputPath, "output", "o", "", "File path to output game state to. Existing files will be overwritten")
playCmd.Flags().BoolVar(&gameState.ViewInBrowser, "browser", false, "View the game in the browser using the Battlesnake game board") playCmd.Flags().BoolVar(&gameState.ViewInBrowser, "browser", false, "View the game in the browser using the Battlesnake game board")
playCmd.Flags().StringVar(&gameState.BoardURL, "board-url", "https://board.battlesnake.com", "Base URL for the game board when using --browser") playCmd.Flags().StringVar(&gameState.BoardURL, "board-url", "https://board.battlesnake.com", "Base URL for the game board when using --browser")
@ -113,7 +120,7 @@ func NewPlayCommand() *cobra.Command {
} }
// Setup a GameState once all the fields have been parsed from the command-line. // Setup a GameState once all the fields have been parsed from the command-line.
func (gameState *GameState) initialize() { func (gameState *GameState) Initialize() error {
// Generate game ID // Generate game ID
gameState.gameID = uuid.New().String() gameState.gameID = uuid.New().String()
@ -130,7 +137,7 @@ func (gameState *GameState) initialize() {
// Load game map // Load game map
gameMap, err := maps.GetMap(gameState.MapName) gameMap, err := maps.GetMap(gameState.MapName)
if err != nil { if err != nil {
log.ERROR.Fatalf("Failed to load game map %#v: %v", gameState.MapName, err) return fmt.Errorf("Failed to load game map %#v: %v", gameState.MapName, err)
} }
gameState.gameMap = gameMap gameState.gameMap = gameMap
@ -153,19 +160,26 @@ func (gameState *GameState) initialize() {
// Initialize snake states as empty until we can ping the snake URLs // Initialize snake states as empty until we can ping the snake URLs
gameState.snakeStates = map[string]SnakeState{} gameState.snakeStates = map[string]SnakeState{}
if gameState.OutputPath != "" {
f, err := os.OpenFile(gameState.OutputPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return fmt.Errorf("Failed to open output file: %w", err)
}
gameState.outputFile = f
}
return nil
} }
// Setup and run a full game. // Setup and run a full game.
func (gameState *GameState) Run() { func (gameState *GameState) Run() {
gameState.initialize()
// Setup local state for snakes // Setup local state for snakes
gameState.snakeStates = gameState.buildSnakesFromOptions() gameState.snakeStates = gameState.buildSnakesFromOptions()
rand.Seed(gameState.Seed) rand.Seed(gameState.Seed)
boardState := gameState.initializeBoardFromArgs() boardState := gameState.initializeBoardFromArgs()
exportGame := gameState.Output != ""
gameExporter := GameExporter{ gameExporter := GameExporter{
game: gameState.createClientGame(), game: gameState.createClientGame(),
@ -173,6 +187,10 @@ func (gameState *GameState) Run() {
winner: SnakeState{}, winner: SnakeState{},
isDraw: false, isDraw: false,
} }
exportGame := gameState.outputFile != nil
if exportGame {
defer gameState.outputFile.Close()
}
boardGame := board.Game{ boardGame := board.Game{
ID: gameState.gameID, ID: gameState.gameID,
@ -258,6 +276,15 @@ func (gameState *GameState) Run() {
} }
} }
// Export final turn
if exportGame {
for _, snakeState := range gameState.snakeStates {
snakeRequest := gameState.getRequestBodyForSnake(boardState, snakeState)
gameExporter.AddSnakeRequest(snakeRequest)
break
}
}
gameExporter.isDraw = false gameExporter.isDraw = false
if len(gameState.snakeStates) > 1 { if len(gameState.snakeStates) > 1 {
@ -291,10 +318,11 @@ func (gameState *GameState) Run() {
} }
if exportGame { if exportGame {
err := gameExporter.FlushToFile(gameState.Output, "JSONL") lines, err := gameExporter.FlushToFile(gameState.outputFile)
if err != nil { if err != nil {
log.ERROR.Fatalf("Unable to export game. Reason: %v", err) log.ERROR.Fatalf("Unable to export game. Reason: %v", err)
} }
log.INFO.Printf("Wrote %d lines to output file: %s", lines, gameState.OutputPath)
} }
} }
@ -515,7 +543,12 @@ func (gameState *GameState) buildSnakesFromOptions() map[string]SnakeState {
var snakeName string var snakeName string
var snakeURL string var snakeURL string
id := uuid.New().String() var id string
if gameState.idGenerator != nil {
id = gameState.idGenerator(i)
} else {
id = uuid.New().String()
}
if i < numNames { if i < numNames {
snakeName = gameState.Names[i] snakeName = gameState.Names[i]
@ -677,6 +710,7 @@ func (gameState *GameState) buildFrameEvent(boardState *rules.BoardState) board.
snakeState := gameState.snakeStates[snake.ID] snakeState := gameState.snakeStates[snake.ID]
latencyMS := snakeState.Latency.Milliseconds() latencyMS := snakeState.Latency.Milliseconds()
// round up latency of 0 to 1, to avoid legacy error display in board
if latencyMS == 0 { if latencyMS == 0 {
latencyMS = 1 latencyMS = 1
} }
@ -734,12 +768,13 @@ func serialiseSnakeRequest(snakeRequest client.SnakeRequest) []byte {
} }
func convertRulesSnake(snake rules.Snake, snakeState SnakeState) client.Snake { func convertRulesSnake(snake rules.Snake, snakeState SnakeState) client.Snake {
latencyMS := snakeState.Latency.Milliseconds()
return client.Snake{ return client.Snake{
ID: snake.ID, ID: snake.ID,
Name: snakeState.Name, Name: snakeState.Name,
Health: snake.Health, Health: snake.Health,
Body: client.CoordFromPointArray(snake.Body), Body: client.CoordFromPointArray(snake.Body),
Latency: "0", Latency: fmt.Sprint(latencyMS),
Head: client.CoordFromPoint(snake.Body[0]), Head: client.CoordFromPoint(snake.Body[0]),
Length: int(len(snake.Body)), Length: int(len(snake.Body)),
Shout: "", Shout: "",

View file

@ -7,6 +7,7 @@ import (
"io" "io"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"strings"
"testing" "testing"
"time" "time"
@ -31,7 +32,7 @@ func buildDefaultGameState() *GameState {
Seed: 1, Seed: 1,
TurnDelay: 0, TurnDelay: 0,
TurnDuration: 0, TurnDuration: 0,
Output: "", OutputPath: "",
FoodSpawnChance: 15, FoodSpawnChance: 15,
MinimumFood: 1, MinimumFood: 1,
HazardDamagePerTurn: 14, HazardDamagePerTurn: 14,
@ -67,7 +68,8 @@ func TestGetIndividualBoardStateForSnake(t *testing.T) {
} }
gameState := buildDefaultGameState() gameState := buildDefaultGameState()
gameState.initialize() err := gameState.Initialize()
require.NoError(t, err)
gameState.gameID = "GAME_ID" gameState.gameID = "GAME_ID"
gameState.snakeStates = map[string]SnakeState{ gameState.snakeStates = map[string]SnakeState{
s1State.ID: s1State, s1State.ID: s1State,
@ -118,7 +120,8 @@ func TestSettingsRequestSerialization(t *testing.T) {
gameState.ShrinkEveryNTurns = 17 gameState.ShrinkEveryNTurns = 17
gameState.GameType = gt gameState.GameType = gt
gameState.initialize() err := gameState.Initialize()
require.NoError(t, err)
gameState.gameID = "GAME_ID" gameState.gameID = "GAME_ID"
gameState.snakeStates = map[string]SnakeState{s1State.ID: s1State, s2State.ID: s2State} gameState.snakeStates = map[string]SnakeState{s1State.ID: s1State, s2State.ID: s2State}
@ -159,13 +162,14 @@ func TestConvertRulesSnakes(t *testing.T) {
Color: "#012345", Color: "#012345",
LastMove: "up", LastMove: "up",
Character: '+', Character: '+',
Latency: time.Millisecond * 42,
}, },
}, },
expected: []client.Snake{ expected: []client.Snake{
{ {
ID: "one", ID: "one",
Name: "ONE", Name: "ONE",
Latency: "0", Latency: "42",
Health: 100, Health: 100,
Body: []client.Coord{{X: 3, Y: 3}, {X: 2, Y: 3}}, Body: []client.Coord{{X: 3, Y: 3}, {X: 2, Y: 3}},
Head: client.Coord{X: 3, Y: 3}, Head: client.Coord{X: 3, Y: 3},
@ -507,9 +511,10 @@ func TestGetMoveForSnake(t *testing.T) {
for _, test := range tests { for _, test := range tests {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
gameState := buildDefaultGameState() gameState := buildDefaultGameState()
gameState.initialize() err := gameState.Initialize()
require.NoError(t, err)
gameState.snakeStates = map[string]SnakeState{test.snakeState.ID: test.snakeState} gameState.snakeStates = map[string]SnakeState{test.snakeState.ID: test.snakeState}
gameState.httpClient = stubHTTPClient{test.responseErr, test.responseCode, test.responseBody, test.responseLatency} gameState.httpClient = stubHTTPClient{test.responseErr, test.responseCode, func(_ string) string { return test.responseBody }, test.responseLatency}
nextSnakeState := gameState.getSnakeUpdate(test.boardState, test.snakeState) nextSnakeState := gameState.getSnakeUpdate(test.boardState, test.snakeState)
if test.expectedSnakeState.Error != nil { if test.expectedSnakeState.Error != nil {
@ -539,9 +544,10 @@ func TestCreateNextBoardState(t *testing.T) {
t.Run(fmt.Sprintf("sequential_%v", sequential), func(t *testing.T) { t.Run(fmt.Sprintf("sequential_%v", sequential), func(t *testing.T) {
gameState := buildDefaultGameState() gameState := buildDefaultGameState()
gameState.Sequential = sequential gameState.Sequential = sequential
gameState.initialize() err := gameState.Initialize()
require.NoError(t, err)
gameState.snakeStates = map[string]SnakeState{s1.ID: snakeState} gameState.snakeStates = map[string]SnakeState{s1.ID: snakeState}
gameState.httpClient = stubHTTPClient{nil, 200, `{"move": "right"}`, 54 * time.Millisecond} gameState.httpClient = stubHTTPClient{nil, 200, func(_ string) string { return `{"move": "right"}` }, 54 * time.Millisecond}
nextBoardState := gameState.createNextBoardState(boardState) nextBoardState := gameState.createNextBoardState(boardState)
snakeState = gameState.snakeStates[s1.ID] snakeState = gameState.snakeStates[s1.ID]
@ -556,18 +562,92 @@ func TestCreateNextBoardState(t *testing.T) {
} }
} }
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 { type stubHTTPClient struct {
err error err error
statusCode int statusCode int
body string body func(url string) string
latency time.Duration latency time.Duration
} }
func (client stubHTTPClient) request() (*http.Response, time.Duration, error) { func (client stubHTTPClient) request(url string) (*http.Response, time.Duration, error) {
if client.err != nil { if client.err != nil {
return nil, client.latency, client.err return nil, client.latency, client.err
} }
body := ioutil.NopCloser(bytes.NewBufferString(client.body)) body := ioutil.NopCloser(bytes.NewBufferString(client.body(url)))
response := &http.Response{ response := &http.Response{
Header: make(http.Header), Header: make(http.Header),
@ -579,9 +659,9 @@ func (client stubHTTPClient) request() (*http.Response, time.Duration, error) {
} }
func (client stubHTTPClient) Get(url string) (*http.Response, time.Duration, error) { func (client stubHTTPClient) Get(url string) (*http.Response, time.Duration, error) {
return client.request() return client.request(url)
} }
func (client stubHTTPClient) Post(url string, contentType string, body io.Reader) (*http.Response, time.Duration, error) { func (client stubHTTPClient) Post(url string, contentType string, body io.Reader) (*http.Response, time.Duration, error) {
return client.request() return client.request(url)
} }

26
cli/commands/testdata/jsonl_game.json vendored Normal file
View file

@ -0,0 +1,26 @@
{
"id": "GAME_ID",
"ruleset": {
"name": "standard",
"version": "cli",
"settings": {
"foodSpawnChance": 1,
"minimumFood": 2,
"hazardDamagePerTurn": 3,
"hazardMap": "",
"hazardMapAuthor": "",
"royale": {
"shrinkEveryNTurns": 4
},
"squad": {
"allowBodyCollisions": false,
"sharedElimination": false,
"sharedHealth": false,
"sharedLength": false
}
}
},
"map": "standard",
"timeout": 500,
"source": ""
}

View file

@ -0,0 +1,5 @@
{
"winnerId": "snk_0",
"winnerName": "example snake",
"isDraw": false
}

110
cli/commands/testdata/jsonl_turn_0.json vendored Normal file
View file

@ -0,0 +1,110 @@
{
"game": {
"id": "GAME_ID",
"ruleset": {
"name": "standard",
"version": "cli",
"settings": {
"foodSpawnChance": 1,
"minimumFood": 2,
"hazardDamagePerTurn": 3,
"hazardMap": "",
"hazardMapAuthor": "",
"royale": {
"shrinkEveryNTurns": 4
},
"squad": {
"allowBodyCollisions": false,
"sharedElimination": false,
"sharedHealth": false,
"sharedLength": false
}
}
},
"map": "standard",
"timeout": 500,
"source": ""
},
"turn": 0,
"board": {
"height": 11,
"width": 11,
"snakes": [
{
"id": "snk_0",
"name": "example snake",
"latency": "0",
"health": 100,
"body": [
{
"x": 1,
"y": 5
},
{
"x": 1,
"y": 5
},
{
"x": 1,
"y": 5
}
],
"head": {
"x": 1,
"y": 5
},
"length": 3,
"shout": "",
"squad": "",
"customizations": {
"color": "#123456",
"head": "safe",
"tail": "curled"
}
}
],
"food": [
{
"x": 0,
"y": 4
},
{
"x": 5,
"y": 5
}
],
"hazards": []
},
"you": {
"id": "snk_0",
"name": "example snake",
"latency": "0",
"health": 100,
"body": [
{
"x": 1,
"y": 5
},
{
"x": 1,
"y": 5
},
{
"x": 1,
"y": 5
}
],
"head": {
"x": 1,
"y": 5
},
"length": 3,
"shout": "",
"squad": "",
"customizations": {
"color": "#123456",
"head": "safe",
"tail": "curled"
}
}
}

110
cli/commands/testdata/jsonl_turn_1.json vendored Normal file
View file

@ -0,0 +1,110 @@
{
"game": {
"id": "GAME_ID",
"ruleset": {
"name": "standard",
"version": "cli",
"settings": {
"foodSpawnChance": 1,
"minimumFood": 2,
"hazardDamagePerTurn": 3,
"hazardMap": "",
"hazardMapAuthor": "",
"royale": {
"shrinkEveryNTurns": 4
},
"squad": {
"allowBodyCollisions": false,
"sharedElimination": false,
"sharedHealth": false,
"sharedLength": false
}
}
},
"map": "standard",
"timeout": 500,
"source": ""
},
"turn": 1,
"board": {
"height": 11,
"width": 11,
"snakes": [
{
"id": "snk_0",
"name": "example snake",
"latency": "42",
"health": 100,
"body": [
{
"x": 1,
"y": 5
},
{
"x": 1,
"y": 5
},
{
"x": 1,
"y": 5
}
],
"head": {
"x": 1,
"y": 5
},
"length": 3,
"shout": "",
"squad": "",
"customizations": {
"color": "#123456",
"head": "safe",
"tail": "curled"
}
}
],
"food": [
{
"x": 0,
"y": 4
},
{
"x": 5,
"y": 5
}
],
"hazards": []
},
"you": {
"id": "snk_0",
"name": "example snake",
"latency": "42",
"health": 100,
"body": [
{
"x": 1,
"y": 5
},
{
"x": 1,
"y": 5
},
{
"x": 1,
"y": 5
}
],
"head": {
"x": 1,
"y": 5
},
"length": 3,
"shout": "",
"squad": "",
"customizations": {
"color": "#123456",
"head": "safe",
"tail": "curled"
}
}
}