Byte-snake-engine/cli/commands/play.go

588 lines
16 KiB
Go
Raw Normal View History

2020-12-10 15:02:29 -08:00
package commands
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"math/rand"
"net/http"
"net/url"
"path"
"strconv"
"sync"
"time"
"github.com/BattlesnakeOfficial/rules"
"github.com/google/uuid"
"github.com/spf13/cobra"
)
type Battlesnake struct {
URL string
Name string
ID string
API string
LastMove string
Squad string
Character rune
}
type Coord struct {
X int32 `json:"x"`
Y int32 `json:"y"`
}
type SnakeResponse struct {
Id string `json:"id"`
Name string `json:"name"`
Health int32 `json:"health"`
Body []Coord `json:"body"`
Latency string `json:"latency"`
Head Coord `json:"head"`
Length int32 `json:"length"`
Shout string `json:"shout"`
Squad string `json:"squad"`
}
type BoardResponse struct {
Height int32 `json:"height"`
Width int32 `json:"width"`
Food []Coord `json:"food"`
Hazards []Coord `json:"hazards"`
Snakes []SnakeResponse `json:"snakes"`
}
type GameResponseRulesetSettings struct {
HazardDamagePerTurn int32 `json:"hazardDamagePerTurn"`
FoodSpawnChance int32 `json:"foodSpawnChance"`
MinimumFood int32 `json:"minimumFood"`
RoyaleSettings RoyaleSettings `json:"royale"`
SquadSettings SquadSettings `json:"squad"`
}
type RoyaleSettings struct {
ShrinkEveryNTurns int32 `json:"shrinkEveryNTurns"`
}
type SquadSettings struct {
AllowBodyCollisions bool `json:"allowBodyCollisions"`
SharedElimination bool `json:"sharedElimination"`
SharedHealth bool `json:"sharedHealth"`
SharedLength bool `json:"sharedLength"`
}
type GameResponseRuleset struct {
Name string `json:"name"`
Version string `json:"version"`
Settings GameResponseRulesetSettings `json:"settings"`
}
type GameResponse struct {
Id string `json:"id"`
Timeout int32 `json:"timeout"`
Ruleset GameResponseRuleset `json:"ruleset"`
}
type ResponsePayload struct {
Game GameResponse `json:"game"`
Turn int32 `json:"turn"`
Board BoardResponse `json:"board"`
You SnakeResponse `json:"you"`
}
type PlayerResponse struct {
Move string `json:"move"`
Shout string `json:"shout"`
}
type PingResponse struct {
APIVersion string `json:"apiversion"`
Author string `json:"author"`
Color string `json:"color"`
Head string `json:"head"`
Tail string `json:"tail"`
Version string `json:"version"`
}
var GameId string
var Turn int32
var Battlesnakes map[string]Battlesnake
var HttpClient http.Client
var Width int32
var Height int32
var Names []string
var URLs []string
var Squads []string
var Timeout int32
var Sequential bool
var GameType string
var ViewMap bool
var Seed int64
var TurnDelay int32
var DebugRequests bool
var FoodSpawnChance int32
var MinimumFood int32
var HazardDamagePerTurn int32
var ShrinkEveryNTurns int32
var playCmd = &cobra.Command{
Use: "play",
2020-12-10 15:02:29 -08:00
Short: "Play a game of Battlesnake locally.",
Long: "Play a game of Battlesnake locally.",
Run: run,
}
func init() {
rootCmd.AddCommand(playCmd)
playCmd.Flags().Int32VarP(&Width, "width", "W", 11, "Width of Board")
playCmd.Flags().Int32VarP(&Height, "height", "H", 11, "Height of Board")
playCmd.Flags().StringArrayVarP(&Names, "name", "n", nil, "Name of Snake")
playCmd.Flags().StringArrayVarP(&URLs, "url", "u", nil, "URL of Snake")
playCmd.Flags().StringArrayVarP(&Names, "squad", "S", nil, "Squad of Snake")
playCmd.Flags().Int32VarP(&Timeout, "timeout", "t", 500, "Request Timeout")
playCmd.Flags().BoolVarP(&Sequential, "sequential", "s", false, "Use Sequential Processing")
playCmd.Flags().StringVarP(&GameType, "gametype", "g", "standard", "Type of Game Rules")
playCmd.Flags().BoolVarP(&ViewMap, "viewmap", "v", false, "View the Map Each Turn")
playCmd.Flags().Int64VarP(&Seed, "seed", "r", time.Now().UTC().UnixNano(), "Random Seed")
playCmd.Flags().Int32VarP(&TurnDelay, "delay", "d", 0, "Turn Delay in Milliseconds")
playCmd.Flags().BoolVar(&DebugRequests, "debug-requests", false, "Log body of all requests sent")
playCmd.Flags().Int32Var(&FoodSpawnChance, "foodSpawnChance", 15, "Percentage chance of spawning a new food every round")
playCmd.Flags().Int32Var(&MinimumFood, "minimumFood", 1, "Minimum food to keep on the board every turn")
playCmd.Flags().Int32Var(&HazardDamagePerTurn, "hazardDamagePerTurn", 14, "Health damage a snake will take when ending its turn in a hazard")
playCmd.Flags().Int32Var(&ShrinkEveryNTurns, "shrinkEveryNTurns", 25, "In Royale mode, the number of turns between generating new hazards")
playCmd.Flags().SortFlags = false
}
var run = func(cmd *cobra.Command, args []string) {
rand.Seed(Seed)
Battlesnakes = make(map[string]Battlesnake)
GameId = uuid.New().String()
Turn = 0
snakes := buildSnakesFromOptions()
ruleset := getRuleset(Seed, snakes)
state := initializeBoardFromArgs(ruleset, snakes)
for _, snake := range snakes {
Battlesnakes[snake.ID] = snake
}
for v := false; !v; v, _ = ruleset.IsGameOver(state) {
Turn++
state = createNextBoardState(ruleset, state, snakes, Turn)
2021-07-04 13:41:44 -07:00
if ViewMap {
printMap(state, Turn)
} else {
log.Printf("[%v]: State: %v\n", Turn, state)
}
if TurnDelay > 0 {
time.Sleep(time.Duration(TurnDelay) * time.Millisecond)
}
}
if GameType == "solo" {
log.Printf("[DONE]: Game completed after %v turns.", Turn)
} else {
var winner string
isDraw := true
for _, snake := range state.Snakes {
if snake.EliminatedCause == rules.NotEliminated {
isDraw = false
winner = Battlesnakes[snake.ID].Name
}
sendEndRequest(ruleset, state, Battlesnakes[snake.ID])
}
if isDraw {
log.Printf("[DONE]: Game completed after %v turns. It was a draw.", Turn)
} else {
log.Printf("[DONE]: Game completed after %v turns. %v is the winner.", Turn, winner)
}
}
}
func getRuleset(seed int64, snakes []Battlesnake) rules.Ruleset {
var ruleset rules.Ruleset
var royale rules.RoyaleRuleset
standard := rules.StandardRuleset{
FoodSpawnChance: FoodSpawnChance,
MinimumFood: MinimumFood,
HazardDamagePerTurn: 0,
}
switch GameType {
case "royale":
standard.HazardDamagePerTurn = HazardDamagePerTurn
royale = rules.RoyaleRuleset{
StandardRuleset: standard,
Seed: seed,
ShrinkEveryNTurns: ShrinkEveryNTurns,
}
ruleset = &royale
case "squad":
squadMap := map[string]string{}
for _, snake := range snakes {
squadMap[snake.ID] = snake.Squad
}
ruleset = &rules.SquadRuleset{
StandardRuleset: standard,
SquadMap: squadMap,
AllowBodyCollisions: true,
SharedElimination: true,
SharedHealth: true,
SharedLength: true,
}
case "solo":
ruleset = &rules.SoloRuleset{
StandardRuleset: standard,
}
case "wrapped":
ruleset = &rules.WrappedRuleset{
StandardRuleset: standard,
}
2021-01-19 15:02:11 -08:00
case "constrictor":
ruleset = &rules.ConstrictorRuleset{
StandardRuleset: standard,
}
default:
ruleset = &standard
}
return ruleset
}
func initializeBoardFromArgs(ruleset rules.Ruleset, snakes []Battlesnake) *rules.BoardState {
if Timeout == 0 {
Timeout = 500
}
HttpClient = http.Client{
Timeout: time.Duration(Timeout) * time.Millisecond,
}
snakeIds := []string{}
for _, snake := range snakes {
snakeIds = append(snakeIds, snake.ID)
}
state, err := rules.CreateDefaultBoardState(Width, Height, snakeIds)
if err != nil {
log.Panic("[PANIC]: Error Initializing Board State")
}
state, err = ruleset.ModifyInitialBoardState(state)
if err != nil {
log.Panic("[PANIC]: Error Initializing Board State")
}
for _, snake := range snakes {
requestBody := getIndividualBoardStateForSnake(state, snake, ruleset)
u, _ := url.ParseRequestURI(snake.URL)
u.Path = path.Join(u.Path, "start")
if DebugRequests {
log.Printf("POST %s: %v", u, string(requestBody))
}
_, err = HttpClient.Post(u.String(), "application/json", bytes.NewBuffer(requestBody))
if err != nil {
log.Printf("[WARN]: Request to %v failed", u.String())
}
}
return state
}
func createNextBoardState(ruleset rules.Ruleset, state *rules.BoardState, snakes []Battlesnake, turn int32) *rules.BoardState {
var moves []rules.SnakeMove
if Sequential {
for _, snake := range snakes {
for _, stateSnake := range state.Snakes {
if snake.ID == stateSnake.ID && stateSnake.EliminatedCause == rules.NotEliminated {
moves = append(moves, getMoveForSnake(ruleset, state, snake))
}
}
}
} else {
var wg sync.WaitGroup
c := make(chan rules.SnakeMove, len(snakes))
for _, snake := range snakes {
for _, stateSnake := range state.Snakes {
if snake.ID == stateSnake.ID && stateSnake.EliminatedCause == rules.NotEliminated {
wg.Add(1)
go getConcurrentMoveForSnake(&wg, ruleset, state, snake, c)
}
}
}
wg.Wait()
close(c)
for move := range c {
moves = append(moves, move)
}
}
for _, move := range moves {
snake := Battlesnakes[move.ID]
snake.LastMove = move.Move
Battlesnakes[move.ID] = snake
}
state, err := ruleset.CreateNextBoardState(state, moves)
if err != nil {
log.Panic("[PANIC]: Error Producing Next Board State")
panic(err)
}
state.Turn = turn
return state
}
func getConcurrentMoveForSnake(wg *sync.WaitGroup, ruleset rules.Ruleset, state *rules.BoardState, snake Battlesnake, c chan rules.SnakeMove) {
defer wg.Done()
c <- getMoveForSnake(ruleset, state, snake)
}
func getMoveForSnake(ruleset rules.Ruleset, state *rules.BoardState, snake Battlesnake) rules.SnakeMove {
requestBody := getIndividualBoardStateForSnake(state, snake, ruleset)
u, _ := url.ParseRequestURI(snake.URL)
u.Path = path.Join(u.Path, "move")
if DebugRequests {
log.Printf("POST %s: %v", u, string(requestBody))
}
res, err := HttpClient.Post(u.String(), "application/json", bytes.NewBuffer(requestBody))
move := snake.LastMove
if err != nil {
log.Printf("[WARN]: Request to %v failed\n", u.String())
log.Printf("Body --> %v\n", string(requestBody))
} else if res.Body != nil {
defer res.Body.Close()
body, readErr := ioutil.ReadAll(res.Body)
if readErr != nil {
log.Fatal(readErr)
} else {
playerResponse := PlayerResponse{}
jsonErr := json.Unmarshal(body, &playerResponse)
if jsonErr != nil {
log.Fatal(jsonErr)
} else {
move = playerResponse.Move
}
}
}
return rules.SnakeMove{ID: snake.ID, Move: move}
}
func sendEndRequest(ruleset rules.Ruleset, state *rules.BoardState, snake Battlesnake) {
requestBody := getIndividualBoardStateForSnake(state, snake, ruleset)
u, _ := url.ParseRequestURI(snake.URL)
u.Path = path.Join(u.Path, "end")
if DebugRequests {
log.Printf("POST %s: %v", u, string(requestBody))
}
_, err := HttpClient.Post(u.String(), "application/json", bytes.NewBuffer(requestBody))
if err != nil {
log.Printf("[WARN]: Request to %v failed", u.String())
}
}
func getIndividualBoardStateForSnake(state *rules.BoardState, snake Battlesnake, ruleset rules.Ruleset) []byte {
var youSnake rules.Snake
for _, snk := range state.Snakes {
if snake.ID == snk.ID {
youSnake = snk
break
}
}
response := ResponsePayload{
Game: GameResponse{Id: GameId, Timeout: Timeout, Ruleset: GameResponseRuleset{
Name: ruleset.Name(),
Version: "cli", // TODO: Use GitHub Release Version
Settings: GameResponseRulesetSettings{
HazardDamagePerTurn: HazardDamagePerTurn,
FoodSpawnChance: FoodSpawnChance,
MinimumFood: MinimumFood,
RoyaleSettings: RoyaleSettings{
ShrinkEveryNTurns: ShrinkEveryNTurns,
},
SquadSettings: SquadSettings{
AllowBodyCollisions: true,
SharedElimination: true,
SharedHealth: true,
SharedLength: true,
},
},
}},
Turn: Turn,
Board: BoardResponse{
Height: state.Height,
Width: state.Width,
Food: coordFromPointArray(state.Food),
Hazards: coordFromPointArray(state.Hazards),
Snakes: buildSnakesResponse(state.Snakes),
},
You: snakeResponseFromSnake(youSnake),
}
responseJson, err := json.Marshal(response)
if err != nil {
log.Panic("[PANIC]: Error Marshalling JSON from State")
panic(err)
}
return responseJson
}
func snakeResponseFromSnake(snake rules.Snake) SnakeResponse {
return SnakeResponse{
Id: snake.ID,
Name: Battlesnakes[snake.ID].Name,
Health: snake.Health,
Body: coordFromPointArray(snake.Body),
Latency: "0",
Head: coordFromPoint(snake.Body[0]),
Length: int32(len(snake.Body)),
Shout: "",
Squad: Battlesnakes[snake.ID].Squad,
}
}
func buildSnakesResponse(snakes []rules.Snake) []SnakeResponse {
var a []SnakeResponse
for _, snake := range snakes {
if snake.EliminatedCause == rules.NotEliminated {
a = append(a, snakeResponseFromSnake(snake))
}
}
return a
}
func coordFromPoint(pt rules.Point) Coord {
return Coord{X: pt.X, Y: pt.Y}
}
func coordFromPointArray(ptArray []rules.Point) []Coord {
a := make([]Coord, 0)
for _, pt := range ptArray {
a = append(a, coordFromPoint(pt))
}
return a
}
func buildSnakesFromOptions() []Battlesnake {
bodyChars := []rune{'■', '⌀', '●', '⍟', '◘', '☺', '□', '☻'}
var numSnakes int
var snakes []Battlesnake
numNames := len(Names)
numURLs := len(URLs)
numSquads := len(Squads)
if numNames > numURLs {
numSnakes = numNames
} 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
var snakeSquad string
id := uuid.New().String()
if i < numNames {
snakeName = Names[i]
} else {
log.Printf("[WARN]: Name for URL %v is missing: a default name will be applied\n", URLs[i])
snakeName = id
}
if i < numURLs {
u, err := url.ParseRequestURI(URLs[i])
if err != nil {
log.Printf("[WARN]: URL %v is not valid: a default will be applied\n", URLs[i])
snakeURL = "https://example.com"
} else {
snakeURL = u.String()
}
} else {
log.Printf("[WARN]: URL for Name %v is missing: a default URL will be applied\n", Names[i])
snakeURL = "https://example.com"
}
if GameType == "squad" {
if i < numSquads {
snakeSquad = Squads[i]
} else {
log.Printf("[WARN]: Squad for URL %v is missing: a default squad will be applied\n", URLs[i])
snakeSquad = strconv.Itoa(i / 2)
}
}
res, err := HttpClient.Get(snakeURL)
api := "0"
if err != nil {
log.Printf("[WARN]: Request to %v failed", snakeURL)
} else if res.Body != nil {
defer res.Body.Close()
body, readErr := ioutil.ReadAll(res.Body)
if readErr != nil {
log.Fatal(readErr)
}
pingResponse := PingResponse{}
jsonErr := json.Unmarshal(body, &pingResponse)
if jsonErr != nil {
log.Fatal(jsonErr)
} else {
api = pingResponse.APIVersion
}
}
snake := Battlesnake{Name: snakeName, URL: snakeURL, ID: id, API: api, LastMove: "up", Character: bodyChars[i%8]}
if GameType == "squad" {
snake.Squad = snakeSquad
}
snakes = append(snakes, snake)
}
return snakes
}
func printMap(state *rules.BoardState, gameTurn int32) {
var o bytes.Buffer
o.WriteString(fmt.Sprintf("Ruleset: %s, Seed: %d, Turn: %v\n", GameType, Seed, gameTurn))
board := make([][]rune, state.Width)
for i := range board {
board[i] = make([]rune, state.Height)
}
for y := int32(0); y < state.Height; y++ {
for x := int32(0); x < state.Width; x++ {
board[x][y] = '◦'
}
}
for _, oob := range state.Hazards {
board[oob.X][oob.Y] = '░'
}
o.WriteString(fmt.Sprintf("Hazards ░: %v\n", state.Hazards))
for _, f := range state.Food {
board[f.X][f.Y] = '⚕'
}
o.WriteString(fmt.Sprintf("Food ⚕: %v\n", state.Food))
for _, s := range state.Snakes {
for _, b := range s.Body {
if b.X >= 0 && b.X < state.Width && b.Y >= 0 && b.Y < state.Height {
board[b.X][b.Y] = Battlesnakes[s.ID].Character
}
}
o.WriteString(fmt.Sprintf("%v %c: %v\n", Battlesnakes[s.ID].Name, Battlesnakes[s.ID].Character, s))
}
for y := state.Height - 1; y >= 0; y-- {
for x := int32(0); x < state.Width; x++ {
o.WriteRune(board[x][y])
}
o.WriteString("\n")
}
log.Print(o.String())
}