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:
Simon Agius Muscat 2021-12-02 18:59:20 +01:00 committed by GitHub
parent 4a9dbbcaef
commit 142a5a6ecf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 184 additions and 38 deletions

4
.gitignore vendored
View file

@ -5,3 +5,7 @@
# General # General
.DS_Store .DS_Store
# Build and Output
/battlesnake
*.jsonl

86
cli/commands/output.go Normal file
View 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)
}

View file

@ -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,35 +361,16 @@ 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),
Name: ruleset.Name(), Turn: Turn,
Version: "cli", // TODO: Use GitHub Release Version Board: convertStateToBoard(state, snakeStates),
Settings: client.RulesetSettings{ You: convertRulesSnake(youSnake, snakeStates[youSnake.ID]),
HazardDamagePerTurn: HazardDamagePerTurn,
FoodSpawnChance: FoodSpawnChance,
MinimumFood: MinimumFood,
RoyaleSettings: client.RoyaleSettings{
ShrinkEveryNTurns: ShrinkEveryNTurns,
},
SquadSettings: client.SquadSettings{
AllowBodyCollisions: true,
SharedElimination: true,
SharedHealth: 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) return request
}
func serialiseSnakeRequest(snakeRequest client.SnakeRequest) []byte {
requestJSON, err := json.Marshal(snakeRequest)
if err != nil { if err != nil {
log.Panic("[PANIC]: Error Marshalling JSON from State") log.Panic("[PANIC]: Error Marshalling JSON from State")
panic(err) panic(err)
@ -354,6 +378,27 @@ func getIndividualBoardStateForSnake(state *rules.BoardState, snakeState SnakeSt
return requestJSON return requestJSON
} }
func createClientGame(ruleset rules.Ruleset) client.Game {
return client.Game{ID: GameId, Timeout: Timeout, Ruleset: client.Ruleset{
Name: ruleset.Name(),
Version: "cli", // TODO: Use GitHub Release Version
Settings: client.RulesetSettings{
HazardDamagePerTurn: HazardDamagePerTurn,
FoodSpawnChance: FoodSpawnChance,
MinimumFood: MinimumFood,
RoyaleSettings: client.RoyaleSettings{
ShrinkEveryNTurns: ShrinkEveryNTurns,
},
SquadSettings: client.SquadSettings{
AllowBodyCollisions: true,
SharedElimination: true,
SharedHealth: true,
SharedLength: true,
},
},
}}
}
func convertRulesSnake(snake rules.Snake, snakeState SnakeState) client.Snake { func convertRulesSnake(snake rules.Snake, snakeState SnakeState) client.Snake {
return client.Snake{ return client.Snake{
ID: snake.ID, ID: snake.ID,
@ -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

View file

@ -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))
} }