diff --git a/board/api.go b/board/api.go new file mode 100644 index 0000000..38c889a --- /dev/null +++ b/board/api.go @@ -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"` +} diff --git a/board/server.go b/board/server.go new file mode 100644 index 0000000..685a03e --- /dev/null +++ b/board/server.go @@ -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 +} diff --git a/cli/commands/play.go b/cli/commands/play.go index 6dcf24d..a2357ae 100644 --- a/cli/commands/play.go +++ b/cli/commands/play.go @@ -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 { diff --git a/cli/commands/play_test.go b/cli/commands/play_test.go index 6c2836e..e9e4d88 100644 --- a/cli/commands/play_test.go +++ b/cli/commands/play_test.go @@ -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) + }) + } +} diff --git a/go.mod b/go.mod index 4493ab8..4b13951 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index f861789..7566cc4 100644 --- a/go.sum +++ b/go.sum @@ -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=