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

72
board/api.go Normal file
View file

@ -0,0 +1,72 @@
package board
import (
"github.com/BattlesnakeOfficial/rules"
)
// Types used to implement the JSON API expected by the board client.
// JSON structure returned by the game status endpoint.
type Game struct {
ID string `json:"ID"`
Status string `json:"Status"`
Width int `json:"Width"`
Height int `json:"Height"`
Ruleset map[string]string `json:"Ruleset"`
SnakeTimeout int `json:"SnakeTimeout"`
Source string `json:"Source"`
RulesetName string `json:"RulesetName"`
RulesStages []string `json:"RulesStages"`
Map string `json:"Map"`
}
// The websocket stream has support for returning different types of events, along with a "type" attribute.
type GameEventType string
const (
EVENT_TYPE_FRAME GameEventType = "frame"
EVENT_TYPE_GAME_END GameEventType = "game_end"
)
// Top-level JSON structure sent in each websocket frame.
type GameEvent struct {
EventType GameEventType `json:"Type"`
Data interface{} `json:"Data"`
}
// Represents a single turn in the game.
type GameFrame struct {
Turn int `json:"Turn"`
Snakes []Snake `json:"Snakes"`
Food []rules.Point `json:"Food"`
Hazards []rules.Point `json:"Hazards"`
}
type GameEnd struct {
Game Game `json:"game"`
}
type Snake struct {
ID string `json:"ID"`
Name string `json:"Name"`
Body []rules.Point `json:"Body"`
Health int `json:"Health"`
Death *Death `json:"Death"`
Color string `json:"Color"`
HeadType string `json:"HeadType"`
TailType string `json:"TailType"`
Latency string `json:"Latency"`
Shout string `json:"Shout"`
Squad string `json:"Squad"`
Author string `json:"Author"`
StatusCode int `json:"StatusCode"`
Error string `json:"Error"`
IsBot bool `json:"IsBot"`
IsEnvironment bool `json:"IsEnvironment"`
}
type Death struct {
Cause string `json:"Cause"`
Turn int `json:"Turn"`
EliminatedBy string `json:"EliminatedBy"`
}

130
board/server.go Normal file
View file

@ -0,0 +1,130 @@
package board
import (
"context"
"encoding/json"
"log"
"net"
"net/http"
"github.com/gorilla/websocket"
"github.com/rs/cors"
)
// A minimal server capable of handling the requests from a single browser client running the board viewer.
type BoardServer struct {
game Game
events chan GameEvent // channel for sending events from the game runner to the browser client
done chan bool // channel for signalling (via closing) that all events have been sent to the browser client
httpServer *http.Server
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
func NewBoardServer(game Game) *BoardServer {
mux := http.NewServeMux()
server := &BoardServer{
game: game,
events: make(chan GameEvent, 1000), // buffered channel to allow game to run ahead of browser client
done: make(chan bool),
httpServer: &http.Server{
Handler: cors.Default().Handler(mux),
},
}
mux.HandleFunc("/games/"+game.ID, server.handleGame)
mux.HandleFunc("/games/"+game.ID+"/events", server.handleWebsocket)
return server
}
// Handle the /games/:id request made by the board to fetch the game metadata.
func (server *BoardServer) handleGame(w http.ResponseWriter, r *http.Request) {
w.Header().Add("Content-Type", "application/json")
err := json.NewEncoder(w).Encode(struct {
Game Game
}{server.game})
if err != nil {
log.Printf("Unable to serialize game: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
}
// Handle the /games/:id/events websocket request made by the board to receive game events.
func (server *BoardServer) handleWebsocket(w http.ResponseWriter, r *http.Request) {
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("Unable to upgrade connection: %v", err)
return
}
defer func() {
err = ws.Close()
if err != nil {
log.Printf("Unable to close websocket stream")
}
}()
for event := range server.events {
jsonStr, err := json.Marshal(event)
if err != nil {
log.Printf("Unable to serialize event for websocket: %v", err)
}
err = ws.WriteMessage(websocket.TextMessage, jsonStr)
if err != nil {
log.Printf("Unable to write to websocket: %v", err)
break
}
}
log.Printf("Finished writing all game events, signalling game server to stop")
close(server.done)
log.Printf("Sending websocket close message")
err = ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
if err != nil {
log.Printf("Problem closing websocket: %v", err)
}
}
func (server *BoardServer) Listen() (string, error) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", err
}
go func() {
err = server.httpServer.Serve(listener)
if err != http.ErrServerClosed {
log.Printf("Error in board HTTP server: %v", err)
}
}()
url := "http://" + listener.Addr().String()
return url, nil
}
func (server *BoardServer) Shutdown() {
close(server.events)
log.Printf("Waiting for websocket clients to finish")
<-server.done
log.Printf("Server is done, exiting")
err := server.httpServer.Shutdown(context.Background())
if err != nil {
log.Printf("Error shutting down HTTP server: %v", err)
}
}
func (server *BoardServer) SendEvent(event GameEvent) {
server.events <- event
}

View file

@ -16,9 +16,11 @@ 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"
)
@ -32,6 +34,10 @@ type SnakeState struct {
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,10 +507,15 @@ 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 {
snakeErr = err
} else {
snakeState.StatusCode = res.StatusCode
if res.Body != nil {
defer res.Body.Close()
body, readErr := ioutil.ReadAll(res.Body)
if readErr != nil {
@ -462,13 +525,21 @@ func (gameState *GameState) buildSnakesFromOptions() map[string]SnakeState {
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 {

View file

@ -5,6 +5,7 @@ import (
"testing"
"github.com/BattlesnakeOfficial/rules"
"github.com/BattlesnakeOfficial/rules/board"
"github.com/BattlesnakeOfficial/rules/client"
"github.com/BattlesnakeOfficial/rules/test"
"github.com/stretchr/testify/require"
@ -221,3 +222,149 @@ func TestConvertRulesSnakes(t *testing.T) {
})
}
}
func TestBuildFrameEvent(t *testing.T) {
tests := []struct {
name string
boardState *rules.BoardState
snakeStates map[string]SnakeState
expected board.GameEvent
}{
{
name: "empty",
boardState: rules.NewBoardState(11, 11),
snakeStates: map[string]SnakeState{},
expected: board.GameEvent{
EventType: board.EVENT_TYPE_FRAME,
Data: board.GameFrame{
Turn: 0,
Snakes: []board.Snake{},
Food: []rules.Point{},
Hazards: []rules.Point{},
},
},
},
{
name: "snake fields",
boardState: &rules.BoardState{
Turn: 99,
Height: 19,
Width: 25,
Food: []rules.Point{{X: 9, Y: 4}},
Snakes: []rules.Snake{
{
ID: "1",
Body: []rules.Point{
{X: 9, Y: 4},
{X: 8, Y: 4},
{X: 7, Y: 4},
},
Health: 97,
EliminatedCause: rules.EliminatedBySelfCollision,
EliminatedOnTurn: 45,
EliminatedBy: "1",
},
},
Hazards: []rules.Point{{X: 8, Y: 6}},
},
snakeStates: map[string]SnakeState{
"1": {
URL: "http://example.com",
Name: "One",
ID: "1",
LastMove: "left",
Color: "#ff00ff",
Head: "silly",
Tail: "default",
Author: "AUTHOR",
Version: "1.5",
Error: nil,
StatusCode: 200,
},
},
expected: board.GameEvent{
EventType: board.EVENT_TYPE_FRAME,
Data: board.GameFrame{
Turn: 99,
Snakes: []board.Snake{
{
ID: "1",
Name: "One",
Body: []rules.Point{{X: 9, Y: 4}, {X: 8, Y: 4}, {X: 7, Y: 4}},
Health: 97,
Death: &board.Death{
Cause: rules.EliminatedBySelfCollision,
Turn: 45,
EliminatedBy: "1",
},
Color: "#ff00ff",
HeadType: "silly",
TailType: "default",
Latency: "1",
Author: "AUTHOR",
StatusCode: 200,
Error: "",
IsBot: false,
IsEnvironment: false,
},
},
Food: []rules.Point{{X: 9, Y: 4}},
Hazards: []rules.Point{{X: 8, Y: 6}},
},
},
},
{
name: "snake errors",
boardState: &rules.BoardState{
Height: 19,
Width: 25,
Snakes: []rules.Snake{
{
ID: "bad_status",
},
{
ID: "connection_error",
},
},
},
snakeStates: map[string]SnakeState{
"bad_status": {
StatusCode: 504,
},
"connection_error": {
Error: fmt.Errorf("error connecting to host"),
},
},
expected: board.GameEvent{
EventType: board.EVENT_TYPE_FRAME,
Data: board.GameFrame{
Snakes: []board.Snake{
{
ID: "bad_status",
Latency: "1",
StatusCode: 504,
Error: "7:Bad HTTP status code 504",
},
{
ID: "connection_error",
Latency: "1",
StatusCode: 0,
Error: "0:Error communicating with server",
},
},
},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
gameState := GameState{
snakeStates: test.snakeStates,
}
actual := gameState.buildFrameEvent(test.boardState)
require.Equalf(t, test.expected, actual, "%#v", actual)
})
}
}

3
go.mod
View file

@ -4,7 +4,10 @@ go 1.18
require (
github.com/google/uuid v1.1.2
github.com/gorilla/websocket v1.4.2
github.com/mitchellh/go-homedir v1.1.0
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
github.com/rs/cors v1.8.2
github.com/spf13/cobra v1.1.1
github.com/spf13/viper v1.7.1
github.com/stretchr/testify v1.4.0

6
go.sum
View file

@ -70,6 +70,7 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
@ -135,6 +136,8 @@ github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@ -151,6 +154,8 @@ github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7z
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rs/cors v1.8.2 h1:KCooALfAYGs415Cwu5ABvv9n9509fSiG5SQJn/AQo4U=
github.com/rs/cors v1.8.2/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
@ -250,6 +255,7 @@ golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150 h1:xHms4gcpe1YE7A3yIllJXP16CMAGuqwO2lX1mTyyRRc=
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=