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
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"
|
||||
"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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue