DEV 1666: Fix /end requests and clean up logging (#109)

* ensure /end request is always called, and refactor win/draw logic

* clean up logging and error handling during initialization

* automatically generate friendly snake names

* title-case snake names

* print out list of alive snake names instead of count

* log snake names, IDs, and URLs at startup

* print out state for turn zero
This commit is contained in:
Rob O'Dwyer 2022-09-02 14:35:55 -07:00 committed by GitHub
parent 006f394355
commit 09aea9c49d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 317 additions and 151 deletions

View file

@ -5,13 +5,12 @@ import (
"encoding/json"
"fmt"
"io/ioutil"
"log"
"math/rand"
"net/http"
"net/url"
"os"
"path"
"strconv"
"strings"
"sync"
"time"
@ -22,6 +21,7 @@ import (
"github.com/google/uuid"
"github.com/pkg/browser"
"github.com/spf13/cobra"
log "github.com/spf13/jwalterweatherman"
)
// Used to store state for each SnakeState while running a local game
@ -55,7 +55,6 @@ type GameState struct {
UseColor bool
Seed int64
TurnDelay int
DebugRequests bool
Output string
ViewInBrowser bool
BoardURL string
@ -98,7 +97,6 @@ func NewPlayCommand() *cobra.Command {
playCmd.Flags().Int64VarP(&gameState.Seed, "seed", "r", time.Now().UTC().UnixNano(), "Random Seed")
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().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")
@ -129,7 +127,7 @@ func (gameState *GameState) initialize() {
// Load game map
gameMap, err := maps.GetMap(gameState.MapName)
if err != nil {
log.Fatalf("Failed to load game map %#v: %v", gameState.MapName, err)
log.ERROR.Fatalf("Failed to load game map %#v: %v", gameState.MapName, err)
}
gameState.gameMap = gameMap
@ -173,10 +171,6 @@ func (gameState *GameState) Run() {
isDraw: false,
}
if gameState.ViewMap {
gameState.printMap(boardState)
}
boardGame := board.Game{
ID: gameState.gameID,
Status: "running",
@ -194,23 +188,30 @@ func (gameState *GameState) Run() {
if gameState.ViewInBrowser {
serverURL, err := boardServer.Listen()
if err != nil {
log.Fatalf("Error starting HTTP server: %v", err)
log.ERROR.Fatalf("Error starting HTTP server: %v", err)
}
log.Printf("Board server listening on %s", serverURL)
defer boardServer.Shutdown()
log.INFO.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)
log.INFO.Printf("Opening board URL: %s", boardURL)
if err := browser.OpenURL(boardURL); err != nil {
log.Printf("Failed to open browser: %v", err)
log.ERROR.Printf("Failed to open browser: %v", err)
}
}
if gameState.ViewInBrowser {
// send turn zero to websocket server
boardServer.SendEvent(gameState.buildFrameEvent(boardState))
}
log.INFO.Printf("Ruleset: %v, Seed: %v", gameState.GameType, gameState.Seed)
if gameState.ViewMap {
gameState.printMap(boardState)
} else {
gameState.printState(boardState)
}
var endTime time.Time
for v := false; !v; v, _ = gameState.ruleset.IsGameOver(boardState) {
if gameState.TurnDuration > 0 {
@ -238,7 +239,7 @@ func (gameState *GameState) Run() {
if gameState.ViewMap {
gameState.printMap(boardState)
} else {
log.Printf("[%v]: State: %v\n", boardState.Turn, boardState)
gameState.printState(boardState)
}
if gameState.TurnDelay > 0 {
@ -254,36 +255,29 @@ func (gameState *GameState) Run() {
}
}
isDraw := true
if gameState.GameType == "solo" {
log.Printf("[DONE]: Game completed after %v turns.", boardState.Turn)
if exportGame {
// These checks for exportGame are present to avoid vacuuming up RAM when an export is not requred.
for _, snakeState := range gameState.snakeStates {
gameExporter.winner = snakeState
break
}
}
} else {
var winner SnakeState
for _, snake := range boardState.Snakes {
snakeState := gameState.snakeStates[snake.ID]
if snake.EliminatedCause == rules.NotEliminated {
isDraw = false
winner = snakeState
}
gameState.sendEndRequest(boardState, snakeState)
gameExporter.isDraw = false
if len(gameState.snakeStates) > 1 {
// A draw is possible if there is more than one snake in the game.
gameExporter.isDraw = true
}
for _, snake := range boardState.Snakes {
snakeState := gameState.snakeStates[snake.ID]
if snake.EliminatedCause == rules.NotEliminated {
gameExporter.isDraw = false
gameExporter.winner = snakeState
}
if isDraw {
log.Printf("[DONE]: Game completed after %v turns. It was a draw.", boardState.Turn)
} else {
log.Printf("[DONE]: Game completed after %v turns. %v is the winner.", boardState.Turn, winner.Name)
}
if exportGame {
gameExporter.winner = winner
gameExporter.isDraw = isDraw
}
gameState.sendEndRequest(boardState, snakeState)
}
if gameExporter.isDraw {
log.INFO.Printf("Game completed after %v turns. It was a draw.", boardState.Turn)
} else if gameExporter.winner.Name != "" {
log.INFO.Printf("Game completed after %v turns. %v was the winner.", boardState.Turn, gameExporter.winner.Name)
} else {
log.INFO.Printf("Game completed after %v turns.", boardState.Turn)
}
if gameState.ViewInBrowser {
@ -296,14 +290,9 @@ func (gameState *GameState) Run() {
if exportGame {
err := gameExporter.FlushToFile(gameState.Output, "JSONL")
if err != nil {
log.Printf("[WARN]: Unable to export game. Reason: %v\n", err.Error())
os.Exit(1)
log.ERROR.Fatalf("Unable to export game. Reason: %v", err)
}
}
if gameState.ViewInBrowser {
boardServer.Shutdown()
}
}
func (gameState *GameState) initializeBoardFromArgs() *rules.BoardState {
@ -313,11 +302,11 @@ func (gameState *GameState) initializeBoardFromArgs() *rules.BoardState {
}
boardState, err := maps.SetupBoard(gameState.gameMap.ID(), gameState.ruleset.Settings(), gameState.Width, gameState.Height, snakeIds)
if err != nil {
log.Fatalf("Error Initializing Board State: %v", err)
log.ERROR.Fatalf("Error Initializing Board State: %v", err)
}
boardState, err = gameState.ruleset.ModifyInitialBoardState(boardState)
if err != nil {
log.Fatalf("Error Initializing Board State: %v", err)
log.ERROR.Fatalf("Error Initializing Board State: %v", err)
}
for _, snakeState := range gameState.snakeStates {
@ -325,12 +314,10 @@ func (gameState *GameState) initializeBoardFromArgs() *rules.BoardState {
requestBody := serialiseSnakeRequest(snakeRequest)
u, _ := url.ParseRequestURI(snakeState.URL)
u.Path = path.Join(u.Path, "start")
if gameState.DebugRequests {
log.Printf("POST %s: %v", u, string(requestBody))
}
log.DEBUG.Printf("POST %s: %v", u, string(requestBody))
_, err = gameState.httpClient.Post(u.String(), "application/json", bytes.NewBuffer(requestBody))
if err != nil {
log.Printf("[WARN]: Request to %v failed", u.String())
log.WARN.Printf("Request to %v failed", u.String())
}
}
return boardState
@ -376,12 +363,12 @@ func (gameState *GameState) createNextBoardState(boardState *rules.BoardState) *
}
boardState, err := gameState.ruleset.CreateNextBoardState(boardState, moves)
if err != nil {
log.Fatalf("Error producing next board state: %v", err)
log.ERROR.Fatalf("Error producing next board state: %v", err)
}
boardState, err = maps.UpdateBoard(gameState.gameMap.ID(), boardState, gameState.ruleset.Settings())
if err != nil {
log.Fatalf("Error updating board with game map: %v", err)
log.ERROR.Fatalf("Error updating board with game map: %v", err)
}
boardState.Turn += 1
@ -394,54 +381,52 @@ func (gameState *GameState) getMoveForSnake(boardState *rules.BoardState, snakeS
requestBody := serialiseSnakeRequest(snakeRequest)
u, _ := url.ParseRequestURI(snakeState.URL)
u.Path = path.Join(u.Path, "move")
if gameState.DebugRequests {
log.Printf("POST %s: %v", u, string(requestBody))
}
log.DEBUG.Printf("POST %s: %v", u, string(requestBody))
res, err := gameState.httpClient.Post(u.String(), "application/json", bytes.NewBuffer(requestBody))
// Use snake's last move as the default in case of an error
snakeMove := rules.SnakeMove{ID: snakeState.ID, Move: snakeState.LastMove}
if err != nil {
log.Printf(
"[WARN]: Request to %v failed\n"+
"\tError: %s\n", u.String(), err)
log.WARN.Printf(
"Request to %v failed\n"+
"\tError: %s", u.String(), err)
return snakeMove
}
if res.Body == nil {
log.Printf(
"[WARN]: Failed to parse response from %v\n"+
"\tError: body is empty\n", u.String())
log.WARN.Printf(
"Failed to parse response from %v\n"+
"\tError: body is empty", u.String())
return snakeMove
}
defer res.Body.Close()
body, readErr := ioutil.ReadAll(res.Body)
if readErr != nil {
log.Printf(
"[WARN]: Failed to read response body from %v\n"+
"\tError: %v\n", u.String(), readErr)
log.WARN.Printf(
"Failed to read response body from %v\n"+
"\tError: %v", u.String(), readErr)
return snakeMove
}
if res.StatusCode != http.StatusOK {
log.Printf(
"[WARN]: Got non-ok status code from %v\n"+
log.WARN.Printf(
"Got non-ok status code from %v\n"+
"\tStatusCode: %d (expected %d)\n"+
"\tBody: %q\n", u.String(), res.StatusCode, http.StatusOK, body)
"\tBody: %q", u.String(), res.StatusCode, http.StatusOK, body)
return snakeMove
}
playerResponse := client.MoveResponse{}
jsonErr := json.Unmarshal(body, &playerResponse)
if jsonErr != nil {
log.Printf(
"[WARN]: Failed to decode JSON from %v\n"+
log.WARN.Printf(
"Failed to decode JSON from %v\n"+
"\tError: %v\n"+
"\tBody: %q\n"+
"\tSee https://docs.battlesnake.com/references/api#post-move", u.String(), jsonErr, body)
return snakeMove
}
if playerResponse.Move != "up" && playerResponse.Move != "down" && playerResponse.Move != "left" && playerResponse.Move != "right" {
log.Printf(
"[WARN]: Failed to parse JSON data from %v\n"+
log.WARN.Printf(
"Failed to parse JSON data from %v\n"+
"\tError: invalid move %q, valid moves are \"up\", \"down\", \"left\" or \"right\"\n"+
"\tBody: %q\n"+
"\tSee https://docs.battlesnake.com/references/api#post-move", u.String(), playerResponse.Move, body)
@ -457,12 +442,10 @@ func (gameState *GameState) sendEndRequest(boardState *rules.BoardState, snakeSt
requestBody := serialiseSnakeRequest(snakeRequest)
u, _ := url.ParseRequestURI(snakeState.URL)
u.Path = path.Join(u.Path, "end")
if gameState.DebugRequests {
log.Printf("POST %s: %v", u, string(requestBody))
}
log.DEBUG.Printf("POST %s: %v", u, string(requestBody))
_, err := gameState.httpClient.Post(u.String(), "application/json", bytes.NewBuffer(requestBody))
if err != nil {
log.Printf("[WARN]: Request to %v failed", u.String())
log.WARN.Printf("Request to %v failed", u.String())
}
}
@ -507,9 +490,6 @@ func (gameState *GameState) buildSnakesFromOptions() map[string]SnakeState {
} else {
numSnakes = numURLs
}
if numNames != numURLs {
log.Println("[WARN]: Number of Names and URLs do not match: defaults will be applied to missing values")
}
for i := int(0); i < numSnakes; i++ {
var snakeName string
var snakeURL string
@ -519,21 +499,18 @@ func (gameState *GameState) buildSnakesFromOptions() map[string]SnakeState {
if i < numNames {
snakeName = gameState.Names[i]
} else {
log.Printf("[WARN]: Name for URL %v is missing: a default name will be applied\n", gameState.URLs[i])
snakeName = id
log.DEBUG.Printf("Name for URL %v is missing: a name will be generated automatically", gameState.URLs[i])
snakeName = GenerateSnakeName()
}
if i < numURLs {
u, err := url.ParseRequestURI(gameState.URLs[i])
if err != nil {
log.Printf("[WARN]: URL %v is not valid: a default will be applied\n", gameState.URLs[i])
snakeURL = "https://example.com"
} else {
snakeURL = u.String()
log.ERROR.Fatalf("URL %v is not valid: %v", gameState.URLs[i], err)
}
snakeURL = u.String()
} else {
log.Printf("[WARN]: URL for Name %v is missing: a default URL will be applied\n", gameState.Names[i])
snakeURL = "https://example.com"
log.ERROR.Fatalf("URL for name %v is missing", gameState.Names[i])
}
snakeState := SnakeState{
@ -542,44 +519,60 @@ func (gameState *GameState) buildSnakesFromOptions() map[string]SnakeState {
var snakeErr error
res, err := gameState.httpClient.Get(snakeURL)
if err != nil {
log.Printf("[WARN]: Request to %v failed: %v", snakeURL, err)
snakeErr = err
} else {
snakeState.StatusCode = res.StatusCode
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
}
}
log.ERROR.Fatalf("Snake metadata request to %v failed: %v", snakeURL, err)
}
snakeState.StatusCode = res.StatusCode
if res.Body == nil {
log.ERROR.Fatalf("Empty response body from snake metadata URL: %v", snakeURL)
}
defer res.Body.Close()
body, readErr := ioutil.ReadAll(res.Body)
if readErr != nil {
log.ERROR.Fatalf("Error reading from snake metadata URL %v: %v", snakeURL, readErr)
}
pingResponse := client.SnakeMetadataResponse{}
jsonErr := json.Unmarshal(body, &pingResponse)
if jsonErr != nil {
log.ERROR.Fatalf("Failed to parse response from %v: %v", snakeURL, jsonErr)
}
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
log.INFO.Printf("Snake ID: %v URL: %v, Name: \"%v\"", snakeState.ID, snakeURL, snakeState.Name)
}
return snakes
}
func (gameState *GameState) printState(boardState *rules.BoardState) {
var aliveSnakeNames []string
for _, snake := range boardState.Snakes {
if snake.EliminatedCause == rules.NotEliminated {
aliveSnakeNames = append(aliveSnakeNames, gameState.snakeStates[snake.ID].Name)
}
}
log.INFO.Printf(
"Turn: %d, Snakes Alive: [%v], Food: %d, Hazards: %d",
boardState.Turn, strings.Join(aliveSnakeNames, ", "), len(boardState.Food), len(boardState.Hazards),
)
}
func (gameState *GameState) printMap(boardState *rules.BoardState) {
var o bytes.Buffer
o.WriteString(fmt.Sprintf("Ruleset: %s, Seed: %d, Turn: %v\n", gameState.GameType, gameState.Seed, boardState.Turn))
o.WriteString(fmt.Sprintf("Turn: %d\n", boardState.Turn))
board := make([][]string, boardState.Width)
for i := range board {
board[i] = make([]string, boardState.Height)
@ -618,21 +611,28 @@ func (gameState *GameState) printMap(boardState *rules.BoardState) {
o.WriteString(fmt.Sprintf("Food ⚕: %v\n", boardState.Food))
}
for _, s := range boardState.Snakes {
red, green, blue := parseSnakeColor(gameState.snakeStates[s.ID].Color)
state := gameState.snakeStates[s.ID]
red, green, blue := parseSnakeColor(state.Color)
for _, b := range s.Body {
if b.X >= 0 && b.X < boardState.Width && b.Y >= 0 && b.Y < boardState.Height {
if gameState.UseColor {
board[b.X][b.Y] = fmt.Sprintf(TERM_FG_RGB+"■", red, green, blue)
} else {
board[b.X][b.Y] = string(gameState.snakeStates[s.ID].Character)
board[b.X][b.Y] = string(state.Character)
}
}
}
if gameState.UseColor {
o.WriteString(fmt.Sprintf("%v "+TERM_FG_RGB+TERM_BG_WHITE+"■■■"+TERM_RESET+": %v\n", gameState.snakeStates[s.ID].Name, red, green, blue, s))
o.WriteString(fmt.Sprintf("%v "+TERM_FG_RGB+TERM_BG_WHITE+"■■■"+TERM_RESET+": ", state.Name, red, green, blue))
} else {
o.WriteString(fmt.Sprintf("%v %c: %v\n", gameState.snakeStates[s.ID].Name, gameState.snakeStates[s.ID].Character, s))
o.WriteString(fmt.Sprintf("%v %c: ", state.Name, state.Character))
}
o.WriteString(fmt.Sprintf("Health: %d", s.Health))
if s.EliminatedCause != rules.NotEliminated {
o.WriteString(fmt.Sprintf(", Eliminated: %v, Turn: %d", s.EliminatedCause, s.EliminatedOnTurn))
}
o.WriteString("\n")
}
for y := boardState.Height - 1; y >= 0; y-- {
if gameState.UseColor {
@ -646,7 +646,7 @@ func (gameState *GameState) printMap(boardState *rules.BoardState) {
}
o.WriteString("\n")
}
log.Print(o.String())
fmt.Println(o.String())
}
func (gameState *GameState) buildFrameEvent(boardState *rules.BoardState) board.GameEvent {
@ -705,7 +705,7 @@ func (gameState *GameState) buildFrameEvent(boardState *rules.BoardState) board.
func serialiseSnakeRequest(snakeRequest client.SnakeRequest) []byte {
requestJSON, err := json.Marshal(snakeRequest)
if err != nil {
log.Fatalf("Error marshalling JSON from State: %v", err)
log.ERROR.Fatalf("Error marshalling JSON from State: %v", err)
}
return requestJSON
}