diff --git a/cli/commands/output.go b/cli/commands/output.go index 28df9df..330c149 100644 --- a/cli/commands/output.go +++ b/cli/commands/output.go @@ -3,9 +3,7 @@ package commands import ( "encoding/json" "fmt" - "os" - - log "github.com/spf13/jwalterweatherman" + "io" "github.com/BattlesnakeOfficial/rules/client" ) @@ -23,37 +21,20 @@ type result struct { IsDraw bool `json:"isDraw"` } -func (ge *GameExporter) FlushToFile(filepath string, format string) error { - var formattedOutput []string - 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) +func (ge *GameExporter) FlushToFile(outputFile io.Writer) (int, error) { + formattedOutput, err := ge.ConvertToJSON() if err != nil { - return err + return 0, err } - defer f.Close() for _, line := range formattedOutput { - _, err := f.WriteString(fmt.Sprintf("%s\n", line)) + _, err := io.WriteString(outputFile, fmt.Sprintf("%s\n", line)) if err != nil { - return err + return 0, err } } - log.DEBUG.Printf("Written %d lines of output to file: %s\n", len(formattedOutput), filepath) - - return nil + return len(formattedOutput), nil } func (ge *GameExporter) ConvertToJSON() ([]string, error) { diff --git a/cli/commands/play.go b/cli/commands/play.go index 51acdcd..3a07e76 100644 --- a/cli/commands/play.go +++ b/cli/commands/play.go @@ -4,10 +4,12 @@ import ( "bytes" "encoding/json" "fmt" + "io" "io/ioutil" "math/rand" "net/http" "net/url" + "os" "path" "strconv" "strings" @@ -56,7 +58,7 @@ type GameState struct { UseColor bool Seed int64 TurnDelay int - Output string + OutputPath string ViewInBrowser bool BoardURL string FoodSpawnChance int @@ -71,6 +73,8 @@ type GameState struct { httpClient TimedHttpClient ruleset rules.Ruleset gameMap maps.GameMap + outputFile io.WriteCloser + idGenerator func(int) string } func NewPlayCommand() *cobra.Command { @@ -81,6 +85,9 @@ func NewPlayCommand() *cobra.Command { Short: "Play a game of Battlesnake locally.", Long: "Play a game of Battlesnake locally.", Run: func(cmd *cobra.Command, args []string) { + if err := gameState.Initialize(); err != nil { + log.ERROR.Fatalf("Error initializing game: %v", err) + } 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().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().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().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. -func (gameState *GameState) initialize() { +func (gameState *GameState) Initialize() error { // Generate game ID gameState.gameID = uuid.New().String() @@ -130,7 +137,7 @@ func (gameState *GameState) initialize() { // Load game map gameMap, err := maps.GetMap(gameState.MapName) 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 @@ -153,19 +160,26 @@ func (gameState *GameState) initialize() { // Initialize snake states as empty until we can ping the snake URLs 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. func (gameState *GameState) Run() { - gameState.initialize() - // Setup local state for snakes gameState.snakeStates = gameState.buildSnakesFromOptions() rand.Seed(gameState.Seed) boardState := gameState.initializeBoardFromArgs() - exportGame := gameState.Output != "" gameExporter := GameExporter{ game: gameState.createClientGame(), @@ -173,6 +187,10 @@ func (gameState *GameState) Run() { winner: SnakeState{}, isDraw: false, } + exportGame := gameState.outputFile != nil + if exportGame { + defer gameState.outputFile.Close() + } boardGame := board.Game{ 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 if len(gameState.snakeStates) > 1 { @@ -291,10 +318,11 @@ func (gameState *GameState) Run() { } if exportGame { - err := gameExporter.FlushToFile(gameState.Output, "JSONL") + lines, err := gameExporter.FlushToFile(gameState.outputFile) if err != nil { 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 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 { snakeName = gameState.Names[i] @@ -677,6 +710,7 @@ func (gameState *GameState) buildFrameEvent(boardState *rules.BoardState) board. snakeState := gameState.snakeStates[snake.ID] latencyMS := snakeState.Latency.Milliseconds() + // round up latency of 0 to 1, to avoid legacy error display in board if latencyMS == 0 { latencyMS = 1 } @@ -734,12 +768,13 @@ func serialiseSnakeRequest(snakeRequest client.SnakeRequest) []byte { } func convertRulesSnake(snake rules.Snake, snakeState SnakeState) client.Snake { + latencyMS := snakeState.Latency.Milliseconds() return client.Snake{ ID: snake.ID, Name: snakeState.Name, Health: snake.Health, Body: client.CoordFromPointArray(snake.Body), - Latency: "0", + Latency: fmt.Sprint(latencyMS), Head: client.CoordFromPoint(snake.Body[0]), Length: int(len(snake.Body)), Shout: "", diff --git a/cli/commands/play_test.go b/cli/commands/play_test.go index 50c9a13..e1c358a 100644 --- a/cli/commands/play_test.go +++ b/cli/commands/play_test.go @@ -7,6 +7,7 @@ import ( "io" "io/ioutil" "net/http" + "strings" "testing" "time" @@ -31,7 +32,7 @@ func buildDefaultGameState() *GameState { Seed: 1, TurnDelay: 0, TurnDuration: 0, - Output: "", + OutputPath: "", FoodSpawnChance: 15, MinimumFood: 1, HazardDamagePerTurn: 14, @@ -67,7 +68,8 @@ func TestGetIndividualBoardStateForSnake(t *testing.T) { } gameState := buildDefaultGameState() - gameState.initialize() + err := gameState.Initialize() + require.NoError(t, err) gameState.gameID = "GAME_ID" gameState.snakeStates = map[string]SnakeState{ s1State.ID: s1State, @@ -118,7 +120,8 @@ func TestSettingsRequestSerialization(t *testing.T) { gameState.ShrinkEveryNTurns = 17 gameState.GameType = gt - gameState.initialize() + err := gameState.Initialize() + require.NoError(t, err) gameState.gameID = "GAME_ID" gameState.snakeStates = map[string]SnakeState{s1State.ID: s1State, s2State.ID: s2State} @@ -159,13 +162,14 @@ func TestConvertRulesSnakes(t *testing.T) { Color: "#012345", LastMove: "up", Character: '+', + Latency: time.Millisecond * 42, }, }, expected: []client.Snake{ { ID: "one", Name: "ONE", - Latency: "0", + Latency: "42", Health: 100, Body: []client.Coord{{X: 3, Y: 3}, {X: 2, Y: 3}}, Head: client.Coord{X: 3, Y: 3}, @@ -507,9 +511,10 @@ func TestGetMoveForSnake(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { gameState := buildDefaultGameState() - gameState.initialize() + err := gameState.Initialize() + require.NoError(t, err) 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) 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) { gameState := buildDefaultGameState() gameState.Sequential = sequential - gameState.initialize() + err := gameState.Initialize() + require.NoError(t, err) 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) 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 { err error statusCode int - body string + body func(url string) string 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 { return nil, client.latency, client.err } - body := ioutil.NopCloser(bytes.NewBufferString(client.body)) + body := ioutil.NopCloser(bytes.NewBufferString(client.body(url))) response := &http.Response{ 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) { - return client.request() + return client.request(url) } func (client stubHTTPClient) Post(url string, contentType string, body io.Reader) (*http.Response, time.Duration, error) { - return client.request() + return client.request(url) } diff --git a/cli/commands/testdata/jsonl_game.json b/cli/commands/testdata/jsonl_game.json new file mode 100644 index 0000000..fa12fee --- /dev/null +++ b/cli/commands/testdata/jsonl_game.json @@ -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": "" +} \ No newline at end of file diff --git a/cli/commands/testdata/jsonl_game_complete.json b/cli/commands/testdata/jsonl_game_complete.json new file mode 100644 index 0000000..f04a05f --- /dev/null +++ b/cli/commands/testdata/jsonl_game_complete.json @@ -0,0 +1,5 @@ +{ + "winnerId": "snk_0", + "winnerName": "example snake", + "isDraw": false +} \ No newline at end of file diff --git a/cli/commands/testdata/jsonl_turn_0.json b/cli/commands/testdata/jsonl_turn_0.json new file mode 100644 index 0000000..7515818 --- /dev/null +++ b/cli/commands/testdata/jsonl_turn_0.json @@ -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" + } + } +} \ No newline at end of file diff --git a/cli/commands/testdata/jsonl_turn_1.json b/cli/commands/testdata/jsonl_turn_1.json new file mode 100644 index 0000000..cd630ea --- /dev/null +++ b/cli/commands/testdata/jsonl_turn_1.json @@ -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" + } + } +} \ No newline at end of file