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

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"
"net/http"
"net/url"
"os"
"path"
"strconv"
"sync"
@ -48,6 +49,7 @@ var ViewMap bool
var Seed int64
var TurnDelay int32
var DebugRequests bool
var Output string
var FoodSpawnChance int32
var MinimumFood int32
@ -76,6 +78,7 @@ func init() {
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().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(&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)
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) {
Turn++
@ -109,25 +120,54 @@ var run = func(cmd *cobra.Command, args []string) {
if TurnDelay > 0 {
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" {
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 {
var winner string
isDraw := true
var winner SnakeState
for _, snake := range state.Snakes {
snakeState := snakeStates[snake.ID]
if snake.EliminatedCause == rules.NotEliminated {
isDraw = false
winner = snakeStates[snake.ID].Name
winner = snakeState
}
sendEndRequest(ruleset, state, snakeStates[snake.ID], snakeStates)
sendEndRequest(ruleset, state, snakeState, snakeStates)
}
if isDraw {
log.Printf("[DONE]: Game completed after %v turns. It was a draw.", Turn)
} 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 {
requestBody := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset)
snakeRequest := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset)
requestBody := serialiseSnakeRequest(snakeRequest)
u, _ := url.ParseRequestURI(snakeState.URL)
u.Path = path.Join(u.Path, "start")
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 {
requestBody := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset)
snakeRequest := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset)
requestBody := serialiseSnakeRequest(snakeRequest)
u, _ := url.ParseRequestURI(snakeState.URL)
u.Path = path.Join(u.Path, "move")
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) {
requestBody := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset)
snakeRequest := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset)
requestBody := serialiseSnakeRequest(snakeRequest)
u, _ := url.ParseRequestURI(snakeState.URL)
u.Path = path.Join(u.Path, "end")
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
for _, snk := range state.Snakes {
if snakeState.ID == snk.ID {
@ -318,35 +361,16 @@ func getIndividualBoardStateForSnake(state *rules.BoardState, snakeState SnakeSt
}
}
request := client.SnakeRequest{
Game: 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,
},
},
}},
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]),
Game: createClientGame(ruleset),
Turn: Turn,
Board: convertStateToBoard(state, 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 {
log.Panic("[PANIC]: Error Marshalling JSON from State")
panic(err)
@ -354,6 +378,27 @@ func getIndividualBoardStateForSnake(state *rules.BoardState, snakeState SnakeSt
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 {
return client.Snake{
ID: snake.ID,
@ -383,6 +428,16 @@ func convertRulesSnakes(snakes []rules.Snake, snakeStates map[string]SnakeState)
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 {
bodyChars := []rune{'■', '⌀', '●', '⍟', '◘', '☺', '□', '☻'}
var numSnakes int

View file

@ -35,7 +35,8 @@ func TestGetIndividualBoardStateForSnake(t *testing.T) {
s1State.ID: s1State,
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))
}