Add export to file (#58)
* Initial addition of a game exporter * Fix snake state bug, remove test logs, fix final output line being empty * Ignore test JSONL file * Added explanation for design decision on the you key in SnakeResponse * Adjust gitignore to be more generic * Retain consistency in usage of pointer * Re-word explanation to refer to requests instead of responses * Remove unnecessary nil check * Check error returned by WriteString * Change file permissions for output file * Initialise gameexporter regardless of whether output is requested * Print error and exit if export to file fails * Added another comment explaining reasoning around export checks * Fixed broken test due to changed return type
This commit is contained in:
parent
4a9dbbcaef
commit
142a5a6ecf
4 changed files with 184 additions and 38 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -5,3 +5,7 @@
|
||||||
|
|
||||||
# General
|
# General
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Build and Output
|
||||||
|
/battlesnake
|
||||||
|
*.jsonl
|
||||||
86
cli/commands/output.go
Normal file
86
cli/commands/output.go
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
package commands
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/BattlesnakeOfficial/rules/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GameExporter struct {
|
||||||
|
game client.Game
|
||||||
|
snakeRequests []client.SnakeRequest
|
||||||
|
winner SnakeState
|
||||||
|
isDraw bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
WinnerID string `json:"winnerId"`
|
||||||
|
WinnerName string `json:"winnerName"`
|
||||||
|
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.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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
for _, line := range formattedOutput {
|
||||||
|
_, err := f.WriteString(fmt.Sprintf("%s\n", line))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Written %d lines of output to file: %s\n", len(formattedOutput), filepath)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ge *GameExporter) ConvertToJSON() ([]string, error) {
|
||||||
|
output := make([]string, 0)
|
||||||
|
serialisedGame, err := json.Marshal(ge.game)
|
||||||
|
if err != nil {
|
||||||
|
return output, err
|
||||||
|
}
|
||||||
|
output = append(output, string(serialisedGame))
|
||||||
|
for _, board := range ge.snakeRequests {
|
||||||
|
serialisedBoard, err := json.Marshal(board)
|
||||||
|
if err != nil {
|
||||||
|
return output, err
|
||||||
|
}
|
||||||
|
output = append(output, string(serialisedBoard))
|
||||||
|
}
|
||||||
|
serialisedResult, err := json.Marshal(result{
|
||||||
|
WinnerID: ge.winner.ID,
|
||||||
|
WinnerName: ge.winner.Name,
|
||||||
|
IsDraw: ge.isDraw,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return output, err
|
||||||
|
}
|
||||||
|
output = append(output, string(serialisedResult))
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ge *GameExporter) AddSnakeRequest(snakeRequest client.SnakeRequest) {
|
||||||
|
ge.snakeRequests = append(ge.snakeRequests, snakeRequest)
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
@ -48,6 +49,7 @@ var ViewMap bool
|
||||||
var Seed int64
|
var Seed int64
|
||||||
var TurnDelay int32
|
var TurnDelay int32
|
||||||
var DebugRequests bool
|
var DebugRequests bool
|
||||||
|
var Output string
|
||||||
|
|
||||||
var FoodSpawnChance int32
|
var FoodSpawnChance int32
|
||||||
var MinimumFood int32
|
var MinimumFood int32
|
||||||
|
|
@ -76,6 +78,7 @@ func init() {
|
||||||
playCmd.Flags().Int64VarP(&Seed, "seed", "r", time.Now().UTC().UnixNano(), "Random Seed")
|
playCmd.Flags().Int64VarP(&Seed, "seed", "r", time.Now().UTC().UnixNano(), "Random Seed")
|
||||||
playCmd.Flags().Int32VarP(&TurnDelay, "delay", "d", 0, "Turn Delay in Milliseconds")
|
playCmd.Flags().Int32VarP(&TurnDelay, "delay", "d", 0, "Turn Delay in Milliseconds")
|
||||||
playCmd.Flags().BoolVar(&DebugRequests, "debug-requests", false, "Log body of all requests sent")
|
playCmd.Flags().BoolVar(&DebugRequests, "debug-requests", false, "Log body of all requests sent")
|
||||||
|
playCmd.Flags().StringVarP(&Output, "output", "o", "", "File path to output game state to. Existing files will be overwritten")
|
||||||
|
|
||||||
playCmd.Flags().Int32Var(&FoodSpawnChance, "foodSpawnChance", 15, "Percentage chance of spawning a new food every round")
|
playCmd.Flags().Int32Var(&FoodSpawnChance, "foodSpawnChance", 15, "Percentage chance of spawning a new food every round")
|
||||||
playCmd.Flags().Int32Var(&MinimumFood, "minimumFood", 1, "Minimum food to keep on the board every turn")
|
playCmd.Flags().Int32Var(&MinimumFood, "minimumFood", 1, "Minimum food to keep on the board every turn")
|
||||||
|
|
@ -95,6 +98,14 @@ var run = func(cmd *cobra.Command, args []string) {
|
||||||
|
|
||||||
ruleset := getRuleset(Seed, snakeStates)
|
ruleset := getRuleset(Seed, snakeStates)
|
||||||
state := initializeBoardFromArgs(ruleset, snakeStates)
|
state := initializeBoardFromArgs(ruleset, snakeStates)
|
||||||
|
exportGame := Output != ""
|
||||||
|
|
||||||
|
gameExporter := GameExporter{
|
||||||
|
game: createClientGame(ruleset),
|
||||||
|
snakeRequests: make([]client.SnakeRequest, 0),
|
||||||
|
winner: SnakeState{},
|
||||||
|
isDraw: false,
|
||||||
|
}
|
||||||
|
|
||||||
for v := false; !v; v, _ = ruleset.IsGameOver(state) {
|
for v := false; !v; v, _ = ruleset.IsGameOver(state) {
|
||||||
Turn++
|
Turn++
|
||||||
|
|
@ -109,25 +120,54 @@ var run = func(cmd *cobra.Command, args []string) {
|
||||||
if TurnDelay > 0 {
|
if TurnDelay > 0 {
|
||||||
time.Sleep(time.Duration(TurnDelay) * time.Millisecond)
|
time.Sleep(time.Duration(TurnDelay) * time.Millisecond)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if exportGame {
|
||||||
|
// The output file was designed in a way so that (nearly) every entry is equivalent to a valid API request.
|
||||||
|
// This is meant to help unlock further development of tools such as replaying a saved game by simply copying each line and sending it as a POST request.
|
||||||
|
// There was a design choice to be made here: the difference between SnakeRequest and BoardState is the `you` key.
|
||||||
|
// We could choose to either store the SnakeRequest of each snake OR to omit the `you` key OR fill the `you` key with one of the snakes
|
||||||
|
// In all cases the API request is technically non-compliant with how the actual API request should be.
|
||||||
|
// The third option (filling the `you` key with an arbitrary snake) is the closest to the actual API request that would need the least manipulation to
|
||||||
|
// be adjusted to look like an API call for a specific snake in the game.
|
||||||
|
snakeState := snakeStates[state.Snakes[0].ID]
|
||||||
|
snakeRequest := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset)
|
||||||
|
gameExporter.AddSnakeRequest(snakeRequest)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isDraw := true
|
||||||
if GameType == "solo" {
|
if GameType == "solo" {
|
||||||
log.Printf("[DONE]: Game completed after %v turns.", Turn)
|
log.Printf("[DONE]: Game completed after %v turns.", Turn)
|
||||||
|
if exportGame {
|
||||||
|
// These checks for exportGame are present to avoid vacuuming up RAM when an export is not requred.
|
||||||
|
gameExporter.winner = snakeStates[state.Snakes[0].ID]
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
var winner string
|
var winner SnakeState
|
||||||
isDraw := true
|
|
||||||
for _, snake := range state.Snakes {
|
for _, snake := range state.Snakes {
|
||||||
|
snakeState := snakeStates[snake.ID]
|
||||||
if snake.EliminatedCause == rules.NotEliminated {
|
if snake.EliminatedCause == rules.NotEliminated {
|
||||||
isDraw = false
|
isDraw = false
|
||||||
winner = snakeStates[snake.ID].Name
|
winner = snakeState
|
||||||
}
|
}
|
||||||
sendEndRequest(ruleset, state, snakeStates[snake.ID], snakeStates)
|
sendEndRequest(ruleset, state, snakeState, snakeStates)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isDraw {
|
if isDraw {
|
||||||
log.Printf("[DONE]: Game completed after %v turns. It was a draw.", Turn)
|
log.Printf("[DONE]: Game completed after %v turns. It was a draw.", Turn)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("[DONE]: Game completed after %v turns. %v is the winner.", Turn, winner)
|
log.Printf("[DONE]: Game completed after %v turns. %v is the winner.", Turn, winner.Name)
|
||||||
|
}
|
||||||
|
if exportGame {
|
||||||
|
gameExporter.winner = winner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if exportGame {
|
||||||
|
err := gameExporter.FlushToFile(Output, "JSONL")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[WARN]: Unable to export game. Reason: %v\n", err.Error())
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -204,7 +244,8 @@ func initializeBoardFromArgs(ruleset rules.Ruleset, snakeStates map[string]Snake
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, snakeState := range snakeStates {
|
for _, snakeState := range snakeStates {
|
||||||
requestBody := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset)
|
snakeRequest := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset)
|
||||||
|
requestBody := serialiseSnakeRequest(snakeRequest)
|
||||||
u, _ := url.ParseRequestURI(snakeState.URL)
|
u, _ := url.ParseRequestURI(snakeState.URL)
|
||||||
u.Path = path.Join(u.Path, "start")
|
u.Path = path.Join(u.Path, "start")
|
||||||
if DebugRequests {
|
if DebugRequests {
|
||||||
|
|
@ -267,7 +308,8 @@ func createNextBoardState(ruleset rules.Ruleset, state *rules.BoardState, snakeS
|
||||||
}
|
}
|
||||||
|
|
||||||
func getMoveForSnake(ruleset rules.Ruleset, state *rules.BoardState, snakeState SnakeState, snakeStates map[string]SnakeState) rules.SnakeMove {
|
func getMoveForSnake(ruleset rules.Ruleset, state *rules.BoardState, snakeState SnakeState, snakeStates map[string]SnakeState) rules.SnakeMove {
|
||||||
requestBody := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset)
|
snakeRequest := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset)
|
||||||
|
requestBody := serialiseSnakeRequest(snakeRequest)
|
||||||
u, _ := url.ParseRequestURI(snakeState.URL)
|
u, _ := url.ParseRequestURI(snakeState.URL)
|
||||||
u.Path = path.Join(u.Path, "move")
|
u.Path = path.Join(u.Path, "move")
|
||||||
if DebugRequests {
|
if DebugRequests {
|
||||||
|
|
@ -297,7 +339,8 @@ func getMoveForSnake(ruleset rules.Ruleset, state *rules.BoardState, snakeState
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendEndRequest(ruleset rules.Ruleset, state *rules.BoardState, snakeState SnakeState, snakeStates map[string]SnakeState) {
|
func sendEndRequest(ruleset rules.Ruleset, state *rules.BoardState, snakeState SnakeState, snakeStates map[string]SnakeState) {
|
||||||
requestBody := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset)
|
snakeRequest := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset)
|
||||||
|
requestBody := serialiseSnakeRequest(snakeRequest)
|
||||||
u, _ := url.ParseRequestURI(snakeState.URL)
|
u, _ := url.ParseRequestURI(snakeState.URL)
|
||||||
u.Path = path.Join(u.Path, "end")
|
u.Path = path.Join(u.Path, "end")
|
||||||
if DebugRequests {
|
if DebugRequests {
|
||||||
|
|
@ -309,7 +352,7 @@ func sendEndRequest(ruleset rules.Ruleset, state *rules.BoardState, snakeState S
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getIndividualBoardStateForSnake(state *rules.BoardState, snakeState SnakeState, snakeStates map[string]SnakeState, ruleset rules.Ruleset) []byte {
|
func getIndividualBoardStateForSnake(state *rules.BoardState, snakeState SnakeState, snakeStates map[string]SnakeState, ruleset rules.Ruleset) client.SnakeRequest {
|
||||||
var youSnake rules.Snake
|
var youSnake rules.Snake
|
||||||
for _, snk := range state.Snakes {
|
for _, snk := range state.Snakes {
|
||||||
if snakeState.ID == snk.ID {
|
if snakeState.ID == snk.ID {
|
||||||
|
|
@ -318,7 +361,25 @@ func getIndividualBoardStateForSnake(state *rules.BoardState, snakeState SnakeSt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
request := client.SnakeRequest{
|
request := client.SnakeRequest{
|
||||||
Game: client.Game{ID: GameId, Timeout: Timeout, Ruleset: client.Ruleset{
|
Game: createClientGame(ruleset),
|
||||||
|
Turn: Turn,
|
||||||
|
Board: convertStateToBoard(state, snakeStates),
|
||||||
|
You: convertRulesSnake(youSnake, snakeStates[youSnake.ID]),
|
||||||
|
}
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
|
func serialiseSnakeRequest(snakeRequest client.SnakeRequest) []byte {
|
||||||
|
requestJSON, err := json.Marshal(snakeRequest)
|
||||||
|
if err != nil {
|
||||||
|
log.Panic("[PANIC]: Error Marshalling JSON from State")
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return requestJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
func createClientGame(ruleset rules.Ruleset) client.Game {
|
||||||
|
return client.Game{ID: GameId, Timeout: Timeout, Ruleset: client.Ruleset{
|
||||||
Name: ruleset.Name(),
|
Name: ruleset.Name(),
|
||||||
Version: "cli", // TODO: Use GitHub Release Version
|
Version: "cli", // TODO: Use GitHub Release Version
|
||||||
Settings: client.RulesetSettings{
|
Settings: client.RulesetSettings{
|
||||||
|
|
@ -335,23 +396,7 @@ func getIndividualBoardStateForSnake(state *rules.BoardState, snakeState SnakeSt
|
||||||
SharedLength: true,
|
SharedLength: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}},
|
}}
|
||||||
Turn: Turn,
|
|
||||||
Board: client.Board{
|
|
||||||
Height: state.Height,
|
|
||||||
Width: state.Width,
|
|
||||||
Food: client.CoordFromPointArray(state.Food),
|
|
||||||
Hazards: client.CoordFromPointArray(state.Hazards),
|
|
||||||
Snakes: convertRulesSnakes(state.Snakes, snakeStates),
|
|
||||||
},
|
|
||||||
You: convertRulesSnake(youSnake, snakeStates[youSnake.ID]),
|
|
||||||
}
|
|
||||||
requestJSON, err := json.Marshal(request)
|
|
||||||
if err != nil {
|
|
||||||
log.Panic("[PANIC]: Error Marshalling JSON from State")
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return requestJSON
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func convertRulesSnake(snake rules.Snake, snakeState SnakeState) client.Snake {
|
func convertRulesSnake(snake rules.Snake, snakeState SnakeState) client.Snake {
|
||||||
|
|
@ -383,6 +428,16 @@ func convertRulesSnakes(snakes []rules.Snake, snakeStates map[string]SnakeState)
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func convertStateToBoard(state *rules.BoardState, snakeStates map[string]SnakeState) client.Board {
|
||||||
|
return client.Board{
|
||||||
|
Height: state.Height,
|
||||||
|
Width: state.Width,
|
||||||
|
Food: client.CoordFromPointArray(state.Food),
|
||||||
|
Hazards: client.CoordFromPointArray(state.Hazards),
|
||||||
|
Snakes: convertRulesSnakes(state.Snakes, snakeStates),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func buildSnakesFromOptions() map[string]SnakeState {
|
func buildSnakesFromOptions() map[string]SnakeState {
|
||||||
bodyChars := []rune{'■', '⌀', '●', '⍟', '◘', '☺', '□', '☻'}
|
bodyChars := []rune{'■', '⌀', '●', '⍟', '◘', '☺', '□', '☻'}
|
||||||
var numSnakes int
|
var numSnakes int
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,8 @@ func TestGetIndividualBoardStateForSnake(t *testing.T) {
|
||||||
s1State.ID: s1State,
|
s1State.ID: s1State,
|
||||||
s2State.ID: s2State,
|
s2State.ID: s2State,
|
||||||
}
|
}
|
||||||
requestBody := getIndividualBoardStateForSnake(state, s1State, snakeStates, &rules.StandardRuleset{})
|
snakeRequest := getIndividualBoardStateForSnake(state, s1State, snakeStates, &rules.StandardRuleset{})
|
||||||
|
requestBody := serialiseSnakeRequest(snakeRequest)
|
||||||
|
|
||||||
test.RequireJSONMatchesFixture(t, "testdata/snake_request_body.json", string(requestBody))
|
test.RequireJSONMatchesFixture(t, "testdata/snake_request_body.json", string(requestBody))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue