DEV 953: Add basic maps support to CLI (#74)

* remove squad support and switch to using pipelines only in RulesBuilder

* remove spawn_food.standard from legacy ruleset definitions

* bugfix: Royale map generates Standard food

* add maps support to CLI

* add automated tests for all registered GameMap implementations

* update README
This commit is contained in:
Rob O'Dwyer 2022-05-25 11:24:27 -07:00 committed by GitHub
parent 3bd1e47bb4
commit 1adbc79168
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 565 additions and 1371 deletions

View file

@ -32,14 +32,14 @@ Usage:
battlesnake play [flags]
Flags:
-W, --width int Width of Board (default 11)
-H, --height int Height of Board (default 11)
-W, --width int Width of Board (default 11)
-H, --height int Height of Board (default 11)
-n, --name stringArray Name of Snake
-u, --url stringArray URL of Snake
-S, --squad stringArray Squad of Snake
-t, --timeout int Request Timeout (default 500)
-s, --sequential Use Sequential Processing
-g, --gametype string Type of Game Rules (default "standard")
-m, --map string Game map to use to populate the board (default "standard")
-v, --viewmap View the Map Each Turn
-c, --color Use color to draw the map
-r, --seed int Random Seed (default 1649588785026867900)

View file

@ -17,6 +17,7 @@ import (
"github.com/BattlesnakeOfficial/rules"
"github.com/BattlesnakeOfficial/rules/client"
"github.com/BattlesnakeOfficial/rules/maps"
"github.com/google/uuid"
"github.com/spf13/cobra"
)
@ -27,128 +28,148 @@ type SnakeState struct {
Name string
ID string
LastMove string
Squad string
Character rune
Color string
Head string
Tail string
}
var GameId string
var Turn int
var HttpClient http.Client
var Width int
var Height int
var Names []string
var URLs []string
var Squads []string
var Timeout int
var TurnDuration int
var Sequential bool
var GameType string
var ViewMap bool
var UseColor bool
var Seed int64
var TurnDelay int
var DebugRequests bool
var Output string
type GameState struct {
// Options
Width int
Height int
Names []string
URLs []string
Timeout int
TurnDuration int
Sequential bool
GameType string
MapName string
ViewMap bool
UseColor bool
Seed int64
TurnDelay int
DebugRequests bool
Output string
FoodSpawnChance int
MinimumFood int
HazardDamagePerTurn int
ShrinkEveryNTurns int
var FoodSpawnChance int
var MinimumFood int
var HazardDamagePerTurn int
var ShrinkEveryNTurns int
var defaultConfig = map[string]string{
// default to standard ruleset
rules.ParamGameType: "standard",
// squad settings default to true (not zero value)
rules.ParamSharedElimination: "true",
rules.ParamSharedHealth: "true",
rules.ParamSharedLength: "true",
rules.ParamAllowBodyCollisions: "true",
// Internal game state
settings map[string]string
snakeStates map[string]SnakeState
gameID string
httpClient http.Client
ruleset rules.Ruleset
gameMap maps.GameMap
}
var playCmd = &cobra.Command{
Use: "play",
Short: "Play a game of Battlesnake locally.",
Long: "Play a game of Battlesnake locally.",
Run: run,
PreRun: playPreRun,
}
func NewPlayCommand() *cobra.Command {
gameState := &GameState{}
func init() {
rootCmd.AddCommand(playCmd)
var playCmd = &cobra.Command{
Use: "play",
Short: "Play a game of Battlesnake locally.",
Long: "Play a game of Battlesnake locally.",
Run: func(cmd *cobra.Command, args []string) {
gameState.Run()
},
}
playCmd.Flags().IntVarP(&Width, "width", "W", 11, "Width of Board")
playCmd.Flags().IntVarP(&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().IntVarP(&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().BoolVarP(&UseColor, "color", "c", false, "Use color to draw the map")
playCmd.Flags().Int64VarP(&Seed, "seed", "r", time.Now().UTC().UnixNano(), "Random Seed")
playCmd.Flags().IntVarP(&TurnDelay, "delay", "d", 0, "Turn Delay in Milliseconds")
playCmd.Flags().IntVarP(&TurnDuration, "duration", "D", 0, "Minimum Turn Duration in Milliseconds")
playCmd.Flags().BoolVar(&DebugRequests, "debug-requests", false, "Log body of all requests sent")
playCmd.Flags().StringVarP(&Output, "output", "o", "", "File path to output game state to. Existing files will be overwritten")
playCmd.Flags().IntVarP(&gameState.Width, "width", "W", 11, "Width of Board")
playCmd.Flags().IntVarP(&gameState.Height, "height", "H", 11, "Height of Board")
playCmd.Flags().StringArrayVarP(&gameState.Names, "name", "n", nil, "Name of Snake")
playCmd.Flags().StringArrayVarP(&gameState.URLs, "url", "u", nil, "URL of Snake")
playCmd.Flags().IntVarP(&gameState.Timeout, "timeout", "t", 500, "Request Timeout")
playCmd.Flags().BoolVarP(&gameState.Sequential, "sequential", "s", false, "Use Sequential Processing")
playCmd.Flags().StringVarP(&gameState.GameType, "gametype", "g", "standard", "Type of Game Rules")
playCmd.Flags().StringVarP(&gameState.MapName, "map", "m", "standard", "Game map to use to populate the board")
playCmd.Flags().BoolVarP(&gameState.ViewMap, "viewmap", "v", false, "View the Map Each Turn")
playCmd.Flags().BoolVarP(&gameState.UseColor, "color", "c", false, "Use color to draw the map")
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().IntVar(&FoodSpawnChance, "foodSpawnChance", 15, "Percentage chance of spawning a new food every round")
playCmd.Flags().IntVar(&MinimumFood, "minimumFood", 1, "Minimum food to keep on the board every turn")
playCmd.Flags().IntVar(&HazardDamagePerTurn, "hazardDamagePerTurn", 14, "Health damage a snake will take when ending its turn in a hazard")
playCmd.Flags().IntVar(&ShrinkEveryNTurns, "shrinkEveryNTurns", 25, "In Royale mode, the number of turns between generating new hazards")
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")
playCmd.Flags().IntVar(&gameState.HazardDamagePerTurn, "hazardDamagePerTurn", 14, "Health damage a snake will take when ending its turn in a hazard")
playCmd.Flags().IntVar(&gameState.ShrinkEveryNTurns, "shrinkEveryNTurns", 25, "In Royale mode, the number of turns between generating new hazards")
playCmd.Flags().SortFlags = false
return playCmd
}
func playPreRun(cmd *cobra.Command, args []string) {
initialiseGameConfig()
// Setup a GameState once all the fields have been parsed from the command-line.
func (gameState *GameState) initialize() {
// Generate game ID
gameState.gameID = uuid.New().String()
// Set up HTTP client with request timeout
if gameState.Timeout == 0 {
gameState.Timeout = 500
}
gameState.httpClient = http.Client{
Timeout: time.Duration(gameState.Timeout) * time.Millisecond,
}
// Load game map
gameMap, err := maps.GetMap(gameState.MapName)
if err != nil {
log.Fatalf("Failed to load game map %#v: %v", gameState.MapName, err)
}
gameState.gameMap = gameMap
// Create settings object
gameState.settings = map[string]string{
rules.ParamGameType: gameState.GameType,
rules.ParamFoodSpawnChance: fmt.Sprint(gameState.FoodSpawnChance),
rules.ParamMinimumFood: fmt.Sprint(gameState.MinimumFood),
rules.ParamHazardDamagePerTurn: fmt.Sprint(gameState.HazardDamagePerTurn),
rules.ParamShrinkEveryNTurns: fmt.Sprint(gameState.ShrinkEveryNTurns),
}
// Build ruleset from settings
ruleset := rules.NewRulesetBuilder().WithSeed(gameState.Seed).WithParams(gameState.settings).Ruleset()
gameState.ruleset = ruleset
// Initialize snake states as empty until we can ping the snake URLs
gameState.snakeStates = map[string]SnakeState{}
}
var run = func(cmd *cobra.Command, args []string) {
rand.Seed(Seed)
// Setup and run a full game.
func (gameState *GameState) Run() {
gameState.initialize()
GameId = uuid.New().String()
Turn = 0
// Setup local state for snakes
gameState.snakeStates = gameState.buildSnakesFromOptions()
var endTime time.Time
snakeStates := buildSnakesFromOptions()
rand.Seed(gameState.Seed)
ruleset := getRuleset(Seed, snakeStates)
state := initializeBoardFromArgs(ruleset, snakeStates)
exportGame := Output != ""
boardState := gameState.initializeBoardFromArgs()
exportGame := gameState.Output != ""
gameExporter := GameExporter{
game: createClientGame(ruleset),
game: gameState.createClientGame(),
snakeRequests: make([]client.SnakeRequest, 0),
winner: SnakeState{},
isDraw: false,
}
for v := false; !v; v, _ = ruleset.IsGameOver(state) {
if TurnDuration > 0 {
endTime = time.Now().Add(time.Duration(TurnDuration) * time.Millisecond)
}
Turn++
state = createNextBoardState(ruleset, state, snakeStates, Turn)
if ViewMap {
printMap(state, snakeStates, Turn)
} else {
log.Printf("[%v]: State: %v\n", Turn, state)
}
if TurnDelay > 0 {
time.Sleep(time.Duration(TurnDelay) * time.Millisecond)
}
if TurnDuration > 0 {
time.Sleep(time.Until(endTime))
if gameState.ViewMap {
gameState.printMap(boardState)
}
var endTime time.Time
for v := false; !v; v, _ = gameState.ruleset.IsGameOver(boardState) {
if gameState.TurnDuration > 0 {
endTime = time.Now().Add(time.Duration(gameState.TurnDuration) * time.Millisecond)
}
// Export game first, if enabled, so that we save the board on turn zero
if exportGame {
// The output file was designed in a way so that (nearly) every entry is equivalent to a valid API request.
// This is meant to help unlock further development of tools such as replaying a saved game by simply copying each line and sending it as a POST request.
@ -157,34 +178,56 @@ var run = func(cmd *cobra.Command, args []string) {
// In all cases the API request is technically non-compliant with how the actual API request should be.
// The third option (filling the `you` key with an arbitrary snake) is the closest to the actual API request that would need the least manipulation to
// be adjusted to look like an API call for a specific snake in the game.
snakeState := snakeStates[state.Snakes[0].ID]
snakeRequest := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset)
gameExporter.AddSnakeRequest(snakeRequest)
for _, snakeState := range gameState.snakeStates {
snakeRequest := gameState.getRequestBodyForSnake(boardState, snakeState)
gameExporter.AddSnakeRequest(snakeRequest)
break
}
}
boardState = gameState.createNextBoardState(boardState)
if gameState.ViewMap {
gameState.printMap(boardState)
} else {
log.Printf("[%v]: State: %v\n", boardState.Turn, boardState)
}
if gameState.TurnDelay > 0 {
time.Sleep(time.Duration(gameState.TurnDelay) * time.Millisecond)
}
if gameState.TurnDuration > 0 {
time.Sleep(time.Until(endTime))
}
}
isDraw := true
if GameType == "solo" {
log.Printf("[DONE]: Game completed after %v turns.", Turn)
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.
gameExporter.winner = snakeStates[state.Snakes[0].ID]
for _, snakeState := range gameState.snakeStates {
gameExporter.winner = snakeState
break
}
}
} else {
var winner SnakeState
for _, snake := range state.Snakes {
snakeState := snakeStates[snake.ID]
for _, snake := range boardState.Snakes {
snakeState := gameState.snakeStates[snake.ID]
if snake.EliminatedCause == rules.NotEliminated {
isDraw = false
winner = snakeState
}
sendEndRequest(ruleset, state, snakeState, snakeStates)
gameState.sendEndRequest(boardState, snakeState)
}
if isDraw {
log.Printf("[DONE]: Game completed after %v turns. It was a draw.", Turn)
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.", Turn, winner.Name)
log.Printf("[DONE]: Game completed after %v turns. %v is the winner.", boardState.Turn, winner.Name)
}
if exportGame {
gameExporter.winner = winner
@ -192,7 +235,7 @@ var run = func(cmd *cobra.Command, args []string) {
}
if exportGame {
err := gameExporter.FlushToFile(Output, "JSONL")
err := gameExporter.FlushToFile(gameState.Output, "JSONL")
if err != nil {
log.Printf("[WARN]: Unable to export game. Reason: %v\n", err.Error())
os.Exit(1)
@ -200,83 +243,57 @@ var run = func(cmd *cobra.Command, args []string) {
}
}
func initialiseGameConfig() {
defaultConfig[rules.ParamGameType] = GameType
defaultConfig[rules.ParamFoodSpawnChance] = fmt.Sprint(FoodSpawnChance)
defaultConfig[rules.ParamMinimumFood] = fmt.Sprint(MinimumFood)
defaultConfig[rules.ParamHazardDamagePerTurn] = fmt.Sprint(HazardDamagePerTurn)
defaultConfig[rules.ParamShrinkEveryNTurns] = fmt.Sprint(ShrinkEveryNTurns)
}
func getRuleset(seed int64, snakeStates map[string]SnakeState) rules.Ruleset {
rb := rules.NewRulesetBuilder().WithSeed(seed).WithParams(defaultConfig)
for _, s := range snakeStates {
rb.AddSnakeToSquad(s.ID, s.Squad)
}
return rb.Ruleset()
}
func initializeBoardFromArgs(ruleset rules.Ruleset, snakeStates map[string]SnakeState) *rules.BoardState {
if Timeout == 0 {
Timeout = 500
}
HttpClient = http.Client{
Timeout: time.Duration(Timeout) * time.Millisecond,
}
func (gameState *GameState) initializeBoardFromArgs() *rules.BoardState {
snakeIds := []string{}
for _, snakeState := range snakeStates {
for _, snakeState := range gameState.snakeStates {
snakeIds = append(snakeIds, snakeState.ID)
}
state, err := rules.CreateDefaultBoardState(rules.GlobalRand, Width, Height, snakeIds)
boardState, err := maps.SetupBoard(gameState.gameMap.ID(), gameState.ruleset.Settings(), gameState.Width, gameState.Height, snakeIds)
if err != nil {
log.Panic("[PANIC]: Error Initializing Board State")
log.Fatalf("Error Initializing Board State: %v", err)
}
state, err = ruleset.ModifyInitialBoardState(state)
boardState, err = gameState.ruleset.ModifyInitialBoardState(boardState)
if err != nil {
log.Panic("[PANIC]: Error Initializing Board State")
log.Fatalf("Error Initializing Board State: %v", err)
}
for _, snakeState := range snakeStates {
snakeRequest := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset)
for _, snakeState := range gameState.snakeStates {
snakeRequest := gameState.getRequestBodyForSnake(boardState, snakeState)
requestBody := serialiseSnakeRequest(snakeRequest)
u, _ := url.ParseRequestURI(snakeState.URL)
u.Path = path.Join(u.Path, "start")
if DebugRequests {
if gameState.DebugRequests {
log.Printf("POST %s: %v", u, string(requestBody))
}
_, err = HttpClient.Post(u.String(), "application/json", bytes.NewBuffer(requestBody))
_, err = gameState.httpClient.Post(u.String(), "application/json", bytes.NewBuffer(requestBody))
if err != nil {
log.Printf("[WARN]: Request to %v failed", u.String())
}
}
return state
return boardState
}
func createNextBoardState(ruleset rules.Ruleset, state *rules.BoardState, snakeStates map[string]SnakeState, turn int) *rules.BoardState {
func (gameState *GameState) createNextBoardState(boardState *rules.BoardState) *rules.BoardState {
var moves []rules.SnakeMove
if Sequential {
for _, snakeState := range snakeStates {
for _, snake := range state.Snakes {
if gameState.Sequential {
for _, snakeState := range gameState.snakeStates {
for _, snake := range boardState.Snakes {
if snakeState.ID == snake.ID && snake.EliminatedCause == rules.NotEliminated {
moves = append(moves, getMoveForSnake(ruleset, state, snakeState, snakeStates))
moves = append(moves, gameState.getMoveForSnake(boardState, snakeState))
}
}
}
} else {
var wg sync.WaitGroup
c := make(chan rules.SnakeMove, len(snakeStates))
c := make(chan rules.SnakeMove, len(gameState.snakeStates))
for _, snakeState := range snakeStates {
for _, snake := range state.Snakes {
for _, snakeState := range gameState.snakeStates {
for _, snake := range boardState.Snakes {
if snakeState.ID == snake.ID && snake.EliminatedCause == rules.NotEliminated {
wg.Add(1)
go func(snakeState SnakeState) {
defer wg.Done()
c <- getMoveForSnake(ruleset, state, snakeState, snakeStates)
c <- gameState.getMoveForSnake(boardState, snakeState)
}(snakeState)
}
}
@ -290,29 +307,34 @@ func createNextBoardState(ruleset rules.Ruleset, state *rules.BoardState, snakeS
}
}
for _, move := range moves {
snakeState := snakeStates[move.ID]
snakeState := gameState.snakeStates[move.ID]
snakeState.LastMove = move.Move
snakeStates[move.ID] = snakeState
gameState.snakeStates[move.ID] = snakeState
}
state, err := ruleset.CreateNextBoardState(state, moves)
boardState, err := gameState.ruleset.CreateNextBoardState(boardState, moves)
if err != nil {
log.Panicf("[PANIC]: Error Producing Next Board State: %v", err)
log.Fatalf("Error producing next board state: %v", err)
}
state.Turn = turn
boardState, err = maps.UpdateBoard(gameState.gameMap.ID(), boardState, gameState.ruleset.Settings())
if err != nil {
log.Fatalf("Error updating board with game map: %v", err)
}
return state
boardState.Turn += 1
return boardState
}
func getMoveForSnake(ruleset rules.Ruleset, state *rules.BoardState, snakeState SnakeState, snakeStates map[string]SnakeState) rules.SnakeMove {
snakeRequest := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset)
func (gameState *GameState) getMoveForSnake(boardState *rules.BoardState, snakeState SnakeState) rules.SnakeMove {
snakeRequest := gameState.getRequestBodyForSnake(boardState, snakeState)
requestBody := serialiseSnakeRequest(snakeRequest)
u, _ := url.ParseRequestURI(snakeState.URL)
u.Path = path.Join(u.Path, "move")
if DebugRequests {
if gameState.DebugRequests {
log.Printf("POST %s: %v", u, string(requestBody))
}
res, err := HttpClient.Post(u.String(), "application/json", bytes.NewBuffer(requestBody))
res, err := gameState.httpClient.Post(u.String(), "application/json", bytes.NewBuffer(requestBody))
move := snakeState.LastMove
if err != nil {
log.Printf("[WARN]: Request to %v failed\n", u.String())
@ -335,100 +357,56 @@ func getMoveForSnake(ruleset rules.Ruleset, state *rules.BoardState, snakeState
return rules.SnakeMove{ID: snakeState.ID, Move: move}
}
func sendEndRequest(ruleset rules.Ruleset, state *rules.BoardState, snakeState SnakeState, snakeStates map[string]SnakeState) {
snakeRequest := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset)
func (gameState *GameState) sendEndRequest(boardState *rules.BoardState, snakeState SnakeState) {
snakeRequest := gameState.getRequestBodyForSnake(boardState, snakeState)
requestBody := serialiseSnakeRequest(snakeRequest)
u, _ := url.ParseRequestURI(snakeState.URL)
u.Path = path.Join(u.Path, "end")
if DebugRequests {
if gameState.DebugRequests {
log.Printf("POST %s: %v", u, string(requestBody))
}
_, err := HttpClient.Post(u.String(), "application/json", bytes.NewBuffer(requestBody))
_, err := gameState.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, snakeState SnakeState, snakeStates map[string]SnakeState, ruleset rules.Ruleset) client.SnakeRequest {
func (gameState *GameState) getRequestBodyForSnake(boardState *rules.BoardState, snakeState SnakeState) client.SnakeRequest {
var youSnake rules.Snake
for _, snk := range state.Snakes {
for _, snk := range boardState.Snakes {
if snakeState.ID == snk.ID {
youSnake = snk
break
}
}
request := client.SnakeRequest{
Game: createClientGame(ruleset),
Turn: Turn,
Board: convertStateToBoard(state, snakeStates),
You: convertRulesSnake(youSnake, snakeStates[youSnake.ID]),
Game: gameState.createClientGame(),
Turn: boardState.Turn,
Board: convertStateToBoard(boardState, gameState.snakeStates),
You: convertRulesSnake(youSnake, snakeState),
}
return request
}
func serialiseSnakeRequest(snakeRequest client.SnakeRequest) []byte {
requestJSON, err := json.Marshal(snakeRequest)
if err != nil {
log.Panic("[PANIC]: Error Marshalling JSON from State")
panic(err)
}
return requestJSON
}
func createClientGame(ruleset rules.Ruleset) client.Game {
return client.Game{ID: GameId, Timeout: Timeout, Ruleset: client.Ruleset{
Name: ruleset.Name(),
Version: "cli", // TODO: Use GitHub Release Version
Settings: ruleset.Settings(),
}}
}
func convertRulesSnake(snake rules.Snake, snakeState SnakeState) client.Snake {
return client.Snake{
ID: snake.ID,
Name: snakeState.Name,
Health: snake.Health,
Body: client.CoordFromPointArray(snake.Body),
Latency: "0",
Head: client.CoordFromPoint(snake.Body[0]),
Length: len(snake.Body),
Shout: "",
Squad: snakeState.Squad,
Customizations: client.Customizations{
Head: snakeState.Head,
Tail: snakeState.Tail,
Color: snakeState.Color,
func (gameState *GameState) createClientGame() client.Game {
return client.Game{
ID: gameState.gameID,
Timeout: gameState.Timeout,
Ruleset: client.Ruleset{
Name: gameState.ruleset.Name(),
Version: "cli", // TODO: Use GitHub Release Version
Settings: gameState.ruleset.Settings(),
},
Map: gameState.gameMap.ID(),
}
}
func convertRulesSnakes(snakes []rules.Snake, snakeStates map[string]SnakeState) []client.Snake {
a := make([]client.Snake, 0)
for _, snake := range snakes {
if snake.EliminatedCause == rules.NotEliminated {
a = append(a, convertRulesSnake(snake, snakeStates[snake.ID]))
}
}
return a
}
func convertStateToBoard(state *rules.BoardState, snakeStates map[string]SnakeState) client.Board {
return client.Board{
Height: state.Height,
Width: state.Width,
Food: client.CoordFromPointArray(state.Food),
Hazards: client.CoordFromPointArray(state.Hazards),
Snakes: convertRulesSnakes(state.Snakes, snakeStates),
}
}
func buildSnakesFromOptions() map[string]SnakeState {
func (gameState *GameState) buildSnakesFromOptions() map[string]SnakeState {
bodyChars := []rune{'■', '⌀', '●', '☻', '◘', '☺', '□', '⍟'}
var numSnakes int
snakes := map[string]SnakeState{}
numNames := len(Names)
numURLs := len(URLs)
numSquads := len(Squads)
numNames := len(gameState.Names)
numURLs := len(gameState.URLs)
if numNames > numURLs {
numSnakes = numNames
} else {
@ -440,42 +418,33 @@ func buildSnakesFromOptions() map[string]SnakeState {
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]
snakeName = gameState.Names[i]
} else {
log.Printf("[WARN]: Name for URL %v is missing: a default name will be applied\n", URLs[i])
log.Printf("[WARN]: Name for URL %v is missing: a default name will be applied\n", gameState.URLs[i])
snakeName = id
}
if i < numURLs {
u, err := url.ParseRequestURI(URLs[i])
u, err := url.ParseRequestURI(gameState.URLs[i])
if err != nil {
log.Printf("[WARN]: URL %v is not valid: a default will be applied\n", URLs[i])
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()
}
} else {
log.Printf("[WARN]: URL for Name %v is missing: a default URL will be applied\n", Names[i])
log.Printf("[WARN]: URL for Name %v is missing: a default URL will be applied\n", gameState.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)
}
}
snakeState := SnakeState{
Name: snakeName, URL: snakeURL, ID: id, LastMove: "up", Character: bodyChars[i%8],
}
res, err := HttpClient.Get(snakeURL)
res, err := gameState.httpClient.Get(snakeURL)
if err != nil {
log.Printf("[WARN]: Request to %v failed: %v", snakeURL, err)
} else if res.Body != nil {
@ -495,14 +464,129 @@ func buildSnakesFromOptions() map[string]SnakeState {
snakeState.Color = pingResponse.Color
}
}
if GameType == "squad" {
snakeState.Squad = snakeSquad
}
snakes[snakeState.ID] = snakeState
}
return snakes
}
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))
board := make([][]string, boardState.Width)
for i := range board {
board[i] = make([]string, boardState.Height)
}
for y := int(0); y < boardState.Height; y++ {
for x := int(0); x < boardState.Width; x++ {
if gameState.UseColor {
board[x][y] = TERM_FG_LIGHTGRAY + "□"
} else {
board[x][y] = "◦"
}
}
}
for _, oob := range boardState.Hazards {
if gameState.UseColor {
board[oob.X][oob.Y] = TERM_BG_GRAY + " " + TERM_BG_WHITE
} else {
board[oob.X][oob.Y] = "░"
}
}
if gameState.UseColor {
o.WriteString(fmt.Sprintf("Hazards "+TERM_BG_GRAY+" "+TERM_RESET+": %v\n", boardState.Hazards))
} else {
o.WriteString(fmt.Sprintf("Hazards ░: %v\n", boardState.Hazards))
}
for _, f := range boardState.Food {
if gameState.UseColor {
board[f.X][f.Y] = TERM_FG_FOOD + "●"
} else {
board[f.X][f.Y] = "⚕"
}
}
if gameState.UseColor {
o.WriteString(fmt.Sprintf("Food "+TERM_FG_FOOD+TERM_BG_WHITE+"●"+TERM_RESET+": %v\n", boardState.Food))
} else {
o.WriteString(fmt.Sprintf("Food ⚕: %v\n", boardState.Food))
}
for _, s := range boardState.Snakes {
red, green, blue := parseSnakeColor(gameState.snakeStates[s.ID].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)
}
}
}
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))
} else {
o.WriteString(fmt.Sprintf("%v %c: %v\n", gameState.snakeStates[s.ID].Name, gameState.snakeStates[s.ID].Character, s))
}
}
for y := boardState.Height - 1; y >= 0; y-- {
if gameState.UseColor {
o.WriteString(TERM_BG_WHITE)
}
for x := int(0); x < boardState.Width; x++ {
o.WriteString(board[x][y])
}
if gameState.UseColor {
o.WriteString(TERM_RESET)
}
o.WriteString("\n")
}
log.Print(o.String())
}
func serialiseSnakeRequest(snakeRequest client.SnakeRequest) []byte {
requestJSON, err := json.Marshal(snakeRequest)
if err != nil {
log.Fatalf("Error marshalling JSON from State: %v", err)
}
return requestJSON
}
func convertRulesSnake(snake rules.Snake, snakeState SnakeState) client.Snake {
return client.Snake{
ID: snake.ID,
Name: snakeState.Name,
Health: snake.Health,
Body: client.CoordFromPointArray(snake.Body),
Latency: "0",
Head: client.CoordFromPoint(snake.Body[0]),
Length: int(len(snake.Body)),
Shout: "",
Customizations: client.Customizations{
Head: snakeState.Head,
Tail: snakeState.Tail,
Color: snakeState.Color,
},
}
}
func convertRulesSnakes(snakes []rules.Snake, snakeStates map[string]SnakeState) []client.Snake {
a := make([]client.Snake, 0)
for _, snake := range snakes {
if snake.EliminatedCause == rules.NotEliminated {
a = append(a, convertRulesSnake(snake, snakeStates[snake.ID]))
}
}
return a
}
func convertStateToBoard(boardState *rules.BoardState, snakeStates map[string]SnakeState) client.Board {
return client.Board{
Height: boardState.Height,
Width: boardState.Width,
Food: client.CoordFromPointArray(boardState.Food),
Hazards: client.CoordFromPointArray(boardState.Hazards),
Snakes: convertRulesSnakes(boardState.Snakes, snakeStates),
}
}
// Parses a color string like "#ef03d3" to rgb values from 0 to 255 or returns
// the default gray if any errors occure
func parseSnakeColor(color string) (int64, int64, int64) {
@ -517,75 +601,3 @@ func parseSnakeColor(color string) (int64, int64, int64) {
// Default gray color from Battlesnake board
return 136, 136, 136
}
func printMap(state *rules.BoardState, snakeStates map[string]SnakeState, gameTurn int) {
var o bytes.Buffer
o.WriteString(fmt.Sprintf("Ruleset: %s, Seed: %d, Turn: %v\n", GameType, Seed, gameTurn))
board := make([][]string, state.Width)
for i := range board {
board[i] = make([]string, state.Height)
}
for y := 0; y < state.Height; y++ {
for x := 0; x < state.Width; x++ {
if UseColor {
board[x][y] = TERM_FG_LIGHTGRAY + "□"
} else {
board[x][y] = "◦"
}
}
}
for _, oob := range state.Hazards {
if UseColor {
board[oob.X][oob.Y] = TERM_BG_GRAY + " " + TERM_BG_WHITE
} else {
board[oob.X][oob.Y] = "░"
}
}
if UseColor {
o.WriteString(fmt.Sprintf("Hazards "+TERM_BG_GRAY+" "+TERM_RESET+": %v\n", state.Hazards))
} else {
o.WriteString(fmt.Sprintf("Hazards ░: %v\n", state.Hazards))
}
for _, f := range state.Food {
if UseColor {
board[f.X][f.Y] = TERM_FG_FOOD + "●"
} else {
board[f.X][f.Y] = "⚕"
}
}
if UseColor {
o.WriteString(fmt.Sprintf("Food "+TERM_FG_FOOD+TERM_BG_WHITE+"●"+TERM_RESET+": %v\n", state.Food))
} else {
o.WriteString(fmt.Sprintf("Food ⚕: %v\n", state.Food))
}
for _, s := range state.Snakes {
red, green, blue := parseSnakeColor(snakeStates[s.ID].Color)
for _, b := range s.Body {
if b.X >= 0 && b.X < state.Width && b.Y >= 0 && b.Y < state.Height {
if UseColor {
board[b.X][b.Y] = fmt.Sprintf(TERM_FG_RGB+"■", red, green, blue)
} else {
board[b.X][b.Y] = string(snakeStates[s.ID].Character)
}
}
}
if UseColor {
o.WriteString(fmt.Sprintf("%v "+TERM_FG_RGB+TERM_BG_WHITE+"■■■"+TERM_RESET+": %v\n", snakeStates[s.ID].Name, red, green, blue, s))
} else {
o.WriteString(fmt.Sprintf("%v %c: %v\n", snakeStates[s.ID].Name, snakeStates[s.ID].Character, s))
}
}
for y := state.Height - 1; y >= 0; y-- {
if UseColor {
o.WriteString(TERM_BG_WHITE)
}
for x := 0; x < state.Width; x++ {
o.WriteString(board[x][y])
}
if UseColor {
o.WriteString(TERM_RESET)
}
o.WriteString("\n")
}
log.Print(o.String())
}

View file

@ -10,6 +10,31 @@ import (
"github.com/stretchr/testify/require"
)
func buildDefaultGameState() *GameState {
gameState := &GameState{
Width: 11,
Height: 11,
Names: nil,
Timeout: 500,
Sequential: false,
GameType: "standard",
MapName: "standard",
ViewMap: false,
UseColor: false,
Seed: 1,
TurnDelay: 0,
TurnDuration: 0,
DebugRequests: false,
Output: "",
FoodSpawnChance: 15,
MinimumFood: 1,
HazardDamagePerTurn: 14,
ShrinkEveryNTurns: 25,
}
return gameState
}
func TestGetIndividualBoardStateForSnake(t *testing.T) {
s1 := rules.Snake{ID: "one", Body: []rules.Point{{X: 3, Y: 3}}}
s2 := rules.Snake{ID: "two", Body: []rules.Point{{X: 4, Y: 3}}}
@ -34,12 +59,16 @@ func TestGetIndividualBoardStateForSnake(t *testing.T) {
Tail: "bolt",
Color: "#654321",
}
snakeStates := map[string]SnakeState{
gameState := buildDefaultGameState()
gameState.initialize()
gameState.gameID = "GAME_ID"
gameState.snakeStates = map[string]SnakeState{
s1State.ID: s1State,
s2State.ID: s2State,
}
initialiseGameConfig() // initialise default config
snakeRequest := getIndividualBoardStateForSnake(state, s1State, snakeStates, getRuleset(0, snakeStates))
snakeRequest := gameState.getRequestBodyForSnake(state, s1State)
requestBody := serialiseSnakeRequest(snakeRequest)
test.RequireJSONMatchesFixture(t, "testdata/snake_request_body.json", string(requestBody))
@ -69,34 +98,25 @@ func TestSettingsRequestSerialization(t *testing.T) {
Tail: "bolt",
Color: "#654321",
}
snakeStates := map[string]SnakeState{s1State.ID: s1State, s2State.ID: s2State}
rsb := rules.NewRulesetBuilder().
WithParams(map[string]string{
// standard
rules.ParamFoodSpawnChance: "11",
rules.ParamMinimumFood: "7",
rules.ParamHazardDamagePerTurn: "19",
rules.ParamHazardMap: "hz_spiral",
rules.ParamHazardMapAuthor: "altersaddle",
// squad
rules.ParamAllowBodyCollisions: "true",
rules.ParamSharedElimination: "false",
rules.ParamSharedHealth: "true",
rules.ParamSharedLength: "false",
// royale
rules.ParamShrinkEveryNTurns: "17",
})
for _, gt := range []string{
rules.GameTypeStandard, rules.GameTypeRoyale, rules.GameTypeSolo,
rules.GameTypeWrapped, rules.GameTypeSquad, rules.GameTypeConstrictor,
rules.GameTypeWrapped, rules.GameTypeConstrictor,
} {
t.Run(gt, func(t *testing.T) {
// apply game type
ruleset := rsb.WithParams(map[string]string{rules.ParamGameType: gt}).Ruleset()
gameState := buildDefaultGameState()
snakeRequest := getIndividualBoardStateForSnake(state, s1State, snakeStates, ruleset)
gameState.FoodSpawnChance = 11
gameState.MinimumFood = 7
gameState.HazardDamagePerTurn = 19
gameState.ShrinkEveryNTurns = 17
gameState.GameType = gt
gameState.initialize()
gameState.gameID = "GAME_ID"
gameState.snakeStates = map[string]SnakeState{s1State.ID: s1State, s2State.ID: s2State}
snakeRequest := gameState.getRequestBodyForSnake(state, s1State)
requestBody := serialiseSnakeRequest(snakeRequest)
t.Log(string(requestBody))
@ -128,7 +148,6 @@ func TestConvertRulesSnakes(t *testing.T) {
ID: "one",
Name: "ONE",
URL: "http://example1.com",
Squad: "squadA",
Head: "a",
Tail: "b",
Color: "#012345",
@ -146,7 +165,6 @@ func TestConvertRulesSnakes(t *testing.T) {
Head: client.Coord{X: 3, Y: 3},
Length: 2,
Shout: "",
Squad: "squadA",
Customizations: client.Customizations{
Color: "#012345",
Head: "a",

View file

@ -2,9 +2,10 @@ package commands
import (
"fmt"
"github.com/spf13/cobra"
"os"
"github.com/spf13/cobra"
homedir "github.com/mitchellh/go-homedir"
"github.com/spf13/viper"
)
@ -18,6 +19,8 @@ var rootCmd = &cobra.Command{
}
func Execute() {
rootCmd.AddCommand(NewPlayCommand())
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)

View file

@ -1,6 +1,6 @@
{
"game": {
"id": "",
"id": "GAME_ID",
"ruleset": {
"name": "standard",
"version": "cli",
@ -11,7 +11,7 @@
"hazardMap": "",
"hazardMapAuthor": "",
"royale": {
"shrinkEveryNTurns": 0
"shrinkEveryNTurns": 25
},
"squad": {
"allowBodyCollisions": false,
@ -21,7 +21,7 @@
}
}
},
"map": "",
"map": "standard",
"timeout": 500,
"source": ""
},

View file

@ -1,6 +1,6 @@
{
"game": {
"id": "",
"id": "GAME_ID",
"ruleset": {
"name": "constrictor",
"version": "cli",
@ -8,10 +8,10 @@
"foodSpawnChance": 11,
"minimumFood": 7,
"hazardDamagePerTurn": 19,
"hazardMap": "hz_spiral",
"hazardMapAuthor": "altersaddle",
"hazardMap": "",
"hazardMapAuthor": "",
"royale": {
"shrinkEveryNTurns": 0
"shrinkEveryNTurns": 17
},
"squad": {
"allowBodyCollisions": false,
@ -21,7 +21,7 @@
}
}
},
"map": "",
"map": "standard",
"timeout": 500,
"source": ""
},

View file

@ -1,6 +1,6 @@
{
"game": {
"id": "",
"id": "GAME_ID",
"ruleset": {
"name": "royale",
"version": "cli",
@ -8,8 +8,8 @@
"foodSpawnChance": 11,
"minimumFood": 7,
"hazardDamagePerTurn": 19,
"hazardMap": "hz_spiral",
"hazardMapAuthor": "altersaddle",
"hazardMap": "",
"hazardMapAuthor": "",
"royale": {
"shrinkEveryNTurns": 17
},
@ -21,7 +21,7 @@
}
}
},
"map": "",
"map": "standard",
"timeout": 500,
"source": ""
},

View file

@ -1,6 +1,6 @@
{
"game": {
"id": "",
"id": "GAME_ID",
"ruleset": {
"name": "solo",
"version": "cli",
@ -8,10 +8,10 @@
"foodSpawnChance": 11,
"minimumFood": 7,
"hazardDamagePerTurn": 19,
"hazardMap": "hz_spiral",
"hazardMapAuthor": "altersaddle",
"hazardMap": "",
"hazardMapAuthor": "",
"royale": {
"shrinkEveryNTurns": 0
"shrinkEveryNTurns": 17
},
"squad": {
"allowBodyCollisions": false,
@ -21,7 +21,7 @@
}
}
},
"map": "",
"map": "standard",
"timeout": 500,
"source": ""
},

View file

@ -11,7 +11,7 @@
"hazardMap": "hz_spiral",
"hazardMapAuthor": "altersaddle",
"royale": {
"shrinkEveryNTurns": 0
"shrinkEveryNTurns": 17
},
"squad": {
"allowBodyCollisions": true,

View file

@ -1,6 +1,6 @@
{
"game": {
"id": "",
"id": "GAME_ID",
"ruleset": {
"name": "standard",
"version": "cli",
@ -8,10 +8,10 @@
"foodSpawnChance": 11,
"minimumFood": 7,
"hazardDamagePerTurn": 19,
"hazardMap": "hz_spiral",
"hazardMapAuthor": "altersaddle",
"hazardMap": "",
"hazardMapAuthor": "",
"royale": {
"shrinkEveryNTurns": 0
"shrinkEveryNTurns": 17
},
"squad": {
"allowBodyCollisions": false,
@ -21,7 +21,7 @@
}
}
},
"map": "",
"map": "standard",
"timeout": 500,
"source": ""
},

View file

@ -1,6 +1,6 @@
{
"game": {
"id": "",
"id": "GAME_ID",
"ruleset": {
"name": "wrapped",
"version": "cli",
@ -8,10 +8,10 @@
"foodSpawnChance": 11,
"minimumFood": 7,
"hazardDamagePerTurn": 19,
"hazardMap": "hz_spiral",
"hazardMapAuthor": "altersaddle",
"hazardMap": "",
"hazardMapAuthor": "",
"royale": {
"shrinkEveryNTurns": 0
"shrinkEveryNTurns": 17
},
"squad": {
"allowBodyCollisions": false,
@ -21,7 +21,7 @@
}
}
},
"map": "",
"map": "standard",
"timeout": 500,
"source": ""
},