From 2956cc4d68c9ba0173c0bc2f07592846787c92ab Mon Sep 17 00:00:00 2001 From: Michael Schoonmaker Date: Sun, 17 Sep 2023 22:29:54 -0400 Subject: [PATCH] Play: Add option to write NDJSON "replay file". This allows users to record the WS frames as they're sent and replay them later with the Board server. --- board/replay.go | 55 ++++++++++++++++++++++++++++++++++++++++++++ cli/commands/play.go | 23 ++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100644 board/replay.go diff --git a/board/replay.go b/board/replay.go new file mode 100644 index 0000000..82ee457 --- /dev/null +++ b/board/replay.go @@ -0,0 +1,55 @@ +package board + +import ( + "os" + "encoding/json" + "fmt" + "io" + + log "github.com/spf13/jwalterweatherman" +) + +type ReplayFile struct { + handle io.WriteCloser +} + +func NewReplayFile(path string) *ReplayFile { + fd, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + log.ERROR.Fatalf("Failed to open replay file: %w", err) + } + + return &ReplayFile{ + handle: fd, + } +} + +func (replay *ReplayFile) WriteGameInfo(game Game) { + // TODO(schoon): Provide a clear delimiter between game info and frames. + // Additionally, we probably want to ensure they're ordered. Take in the + // `Game` in `NewReplayFile` (much like `NewBoardServer` and write + // game info as front matter? + jsonStr, err := json.Marshal(struct { + Game Game + }{game}) + if err != nil { + log.ERROR.Printf("Unable to serialize event for replay file: %v", err) + } + + _, err = io.WriteString(replay.handle, fmt.Sprintf("%s\n", jsonStr)) + if err != nil { + log.WARN.Printf("Unable to write to replay file: %v", err) + } +} + +func (replay *ReplayFile) WriteEvent(event GameEvent) { + jsonStr, err := json.Marshal(event) + if err != nil { + log.ERROR.Printf("Unable to serialize event for replay file: %v", err) + } + + _, err = io.WriteString(replay.handle, fmt.Sprintf("%s\n", jsonStr)) + if err != nil { + log.WARN.Printf("Unable to write to replay file: %v", err) + } +} diff --git a/cli/commands/play.go b/cli/commands/play.go index caabe2d..1b7cc1c 100644 --- a/cli/commands/play.go +++ b/cli/commands/play.go @@ -59,6 +59,7 @@ type GameState struct { Seed int64 TurnDelay int OutputPath string + ReplayFilePath string ViewInBrowser bool BoardURL string FoodSpawnChance int @@ -74,6 +75,7 @@ type GameState struct { ruleset rules.Ruleset gameMap maps.GameMap outputFile io.WriteCloser + replayFile *board.ReplayFile idGenerator func(int) string } @@ -108,6 +110,7 @@ func NewPlayCommand() *cobra.Command { 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.OutputPath, "output", "o", "", "File path to output game state to. Existing files will be overwritten") + playCmd.Flags().StringVar(&gameState.ReplayFilePath, "replay", "", "File path to write game frames to for replaying later. 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") @@ -170,6 +173,10 @@ func (gameState *GameState) Initialize() error { gameState.outputFile = f } + if gameState.ReplayFilePath != "" { + gameState.replayFile = board.NewReplayFile(gameState.ReplayFilePath); + } + return nil } @@ -235,6 +242,11 @@ func (gameState *GameState) Run() error { boardServer.SendEvent(gameState.buildFrameEvent(boardState)) } + if gameState.replayFile != nil { + gameState.replayFile.WriteGameInfo(boardGame) + gameState.replayFile.WriteEvent(gameState.buildFrameEvent(boardState)) + } + log.INFO.Printf("Ruleset: %v, Seed: %v", gameState.GameType, gameState.Seed) if gameState.ViewMap { @@ -293,6 +305,10 @@ func (gameState *GameState) Run() error { boardServer.SendEvent(gameState.buildFrameEvent(boardState)) } + if gameState.replayFile != nil { + gameState.replayFile.WriteEvent(gameState.buildFrameEvent(boardState)) + } + if exportGame { for _, snakeState := range gameState.snakeStates { snakeRequest := gameState.getRequestBodyForSnake(boardState, snakeState) @@ -334,6 +350,13 @@ func (gameState *GameState) Run() error { }) } + if gameState.replayFile != nil { + gameState.replayFile.WriteEvent(board.GameEvent{ + EventType: board.EVENT_TYPE_GAME_END, + Data: boardGame, + }) + } + if exportGame { lines, err := gameExporter.FlushToFile(gameState.outputFile) if err != nil {