DEV 1404: Support streaming CLI games to the browser board (#88)

* add minimal support for serving a game to the board UI

* refactor into new board package

* support reporting errors and author names to board

* support passing an alternate board URL

* avoid using IPv6 for local URL

* use rules.Point instead of a custom Point type for board package

* use zero for generic communication error code in cli

* rename createGameEvent to buildFrameEvent

* tests for conversion from boardState/snakeState to game frame
This commit is contained in:
Rob O'Dwyer 2022-06-28 16:17:44 -07:00 committed by GitHub
parent f6c3ed0791
commit a451cda9c4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 504 additions and 22 deletions

View file

@ -16,22 +16,28 @@ import (
"time"
"github.com/BattlesnakeOfficial/rules"
"github.com/BattlesnakeOfficial/rules/board"
"github.com/BattlesnakeOfficial/rules/client"
"github.com/BattlesnakeOfficial/rules/maps"
"github.com/google/uuid"
"github.com/pkg/browser"
"github.com/spf13/cobra"
)
// Used to store state for each SnakeState while running a local game
type SnakeState struct {
URL string
Name string
ID string
LastMove string
Character rune
Color string
Head string
Tail string
URL string
Name string
ID string
LastMove string
Character rune
Color string
Head string
Tail string
Author string
Version string
Error error
StatusCode int
}
type GameState struct {
@ -51,6 +57,8 @@ type GameState struct {
TurnDelay int
DebugRequests bool
Output string
ViewInBrowser bool
BoardURL string
FoodSpawnChance int
MinimumFood int
HazardDamagePerTurn int
@ -92,6 +100,8 @@ func NewPlayCommand() *cobra.Command {
playCmd.Flags().IntVarP(&gameState.TurnDuration, "duration", "D", 0, "Minimum Turn Duration in Milliseconds")
playCmd.Flags().BoolVar(&gameState.DebugRequests, "debug-requests", false, "Log body of all requests sent")
playCmd.Flags().StringVarP(&gameState.Output, "output", "o", "", "File path to output game state to. 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")
playCmd.Flags().IntVar(&gameState.FoodSpawnChance, "foodSpawnChance", 15, "Percentage chance of spawning a new food every round")
playCmd.Flags().IntVar(&gameState.MinimumFood, "minimumFood", 1, "Minimum food to keep on the board every turn")
@ -167,6 +177,40 @@ func (gameState *GameState) Run() {
gameState.printMap(boardState)
}
boardGame := board.Game{
ID: gameState.gameID,
Status: "running",
Width: gameState.Width,
Height: gameState.Height,
Ruleset: map[string]string{
rules.ParamGameType: gameState.GameType,
},
RulesetName: gameState.GameType,
RulesStages: []string{},
Map: gameState.MapName,
}
boardServer := board.NewBoardServer(boardGame)
if gameState.ViewInBrowser {
serverURL, err := boardServer.Listen()
if err != nil {
log.Fatalf("Error starting HTTP server: %v", err)
}
log.Printf("Board server listening on %s", serverURL)
boardURL := fmt.Sprintf(gameState.BoardURL+"?engine=%s&game=%s&autoplay=true", serverURL, gameState.gameID)
log.Printf("Opening board URL: %s", boardURL)
if err := browser.OpenURL(boardURL); err != nil {
log.Printf("Failed to open browser: %v", err)
}
}
if gameState.ViewInBrowser {
// send turn zero to websocket server
boardServer.SendEvent(gameState.buildFrameEvent(boardState))
}
var endTime time.Time
for v := false; !v; v, _ = gameState.ruleset.IsGameOver(boardState) {
if gameState.TurnDuration > 0 {
@ -205,6 +249,9 @@ func (gameState *GameState) Run() {
time.Sleep(time.Until(endTime))
}
if gameState.ViewInBrowser {
boardServer.SendEvent(gameState.buildFrameEvent(boardState))
}
}
isDraw := true
@ -239,6 +286,13 @@ func (gameState *GameState) Run() {
}
}
if gameState.ViewInBrowser {
boardServer.SendEvent(board.GameEvent{
EventType: board.EVENT_TYPE_GAME_END,
Data: boardGame,
})
}
if exportGame {
err := gameExporter.FlushToFile(gameState.Output, "JSONL")
if err != nil {
@ -246,6 +300,10 @@ func (gameState *GameState) Run() {
os.Exit(1)
}
}
if gameState.ViewInBrowser {
boardServer.Shutdown()
}
}
func (gameState *GameState) initializeBoardFromArgs() *rules.BoardState {
@ -449,26 +507,39 @@ func (gameState *GameState) buildSnakesFromOptions() map[string]SnakeState {
snakeState := SnakeState{
Name: snakeName, URL: snakeURL, ID: id, LastMove: "up", Character: bodyChars[i%8],
}
var snakeErr error
res, err := gameState.httpClient.Get(snakeURL)
if err != nil {
log.Printf("[WARN]: Request to %v failed: %v", snakeURL, err)
} else if res.Body != nil {
defer res.Body.Close()
body, readErr := ioutil.ReadAll(res.Body)
if readErr != nil {
log.Fatal(readErr)
}
snakeErr = err
} else {
snakeState.StatusCode = res.StatusCode
pingResponse := client.SnakeMetadataResponse{}
jsonErr := json.Unmarshal(body, &pingResponse)
if jsonErr != nil {
log.Printf("Error reading response from %v: %v", snakeURL, jsonErr)
} else {
snakeState.Head = pingResponse.Head
snakeState.Tail = pingResponse.Tail
snakeState.Color = pingResponse.Color
if res.Body != nil {
defer res.Body.Close()
body, readErr := ioutil.ReadAll(res.Body)
if readErr != nil {
log.Fatal(readErr)
}
pingResponse := client.SnakeMetadataResponse{}
jsonErr := json.Unmarshal(body, &pingResponse)
if jsonErr != nil {
snakeErr = jsonErr
log.Printf("Error reading response from %v: %v", snakeURL, jsonErr)
} else {
snakeState.Head = pingResponse.Head
snakeState.Tail = pingResponse.Tail
snakeState.Color = pingResponse.Color
snakeState.Author = pingResponse.Author
snakeState.Version = pingResponse.Version
}
}
}
if snakeErr != nil {
snakeState.Error = snakeErr
}
snakes[snakeState.ID] = snakeState
}
return snakes
@ -546,6 +617,59 @@ func (gameState *GameState) printMap(boardState *rules.BoardState) {
log.Print(o.String())
}
func (gameState *GameState) buildFrameEvent(boardState *rules.BoardState) board.GameEvent {
snakes := []board.Snake{}
for _, snake := range boardState.Snakes {
snakeState := gameState.snakeStates[snake.ID]
convertedSnake := board.Snake{
ID: snake.ID,
Name: snakeState.Name,
Body: snake.Body,
Health: snake.Health,
Color: snakeState.Color,
HeadType: snakeState.Head,
TailType: snakeState.Tail,
Author: snakeState.Author,
StatusCode: snakeState.StatusCode,
IsBot: false,
IsEnvironment: false,
// Not supporting local latency for now - there are better ways to test performance locally
Latency: "1",
}
if snakeState.Error != nil {
// Instead of trying to keep in sync with the production engine's
// error detection and messages, just show a generic error and rely
// on the CLI logs to show what really happened.
convertedSnake.Error = "0:Error communicating with server"
} else if snakeState.StatusCode != http.StatusOK {
convertedSnake.Error = fmt.Sprintf("7:Bad HTTP status code %d", snakeState.StatusCode)
}
if snake.EliminatedCause != rules.NotEliminated {
convertedSnake.Death = &board.Death{
Cause: snake.EliminatedCause,
Turn: snake.EliminatedOnTurn,
EliminatedBy: snake.EliminatedBy,
}
}
snakes = append(snakes, convertedSnake)
}
gameFrame := board.GameFrame{
Turn: boardState.Turn,
Snakes: snakes,
Food: boardState.Food,
Hazards: boardState.Hazards,
}
return board.GameEvent{
EventType: board.EVENT_TYPE_FRAME,
Data: gameFrame,
}
}
func serialiseSnakeRequest(snakeRequest client.SnakeRequest) []byte {
requestJSON, err := json.Marshal(snakeRequest)
if err != nil {