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:
parent
f6c3ed0791
commit
a451cda9c4
6 changed files with 504 additions and 22 deletions
72
board/api.go
Normal file
72
board/api.go
Normal 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
130
board/server.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue