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

View file

@ -17,6 +17,7 @@ import (
"github.com/BattlesnakeOfficial/rules" "github.com/BattlesnakeOfficial/rules"
"github.com/BattlesnakeOfficial/rules/client" "github.com/BattlesnakeOfficial/rules/client"
"github.com/BattlesnakeOfficial/rules/maps"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -27,128 +28,148 @@ type SnakeState struct {
Name string Name string
ID string ID string
LastMove string LastMove string
Squad string
Character rune Character rune
Color string Color string
Head string Head string
Tail string Tail string
} }
var GameId string type GameState struct {
var Turn int // Options
var HttpClient http.Client Width int
var Width int Height int
var Height int Names []string
var Names []string URLs []string
var URLs []string Timeout int
var Squads []string TurnDuration int
var Timeout int Sequential bool
var TurnDuration int GameType string
var Sequential bool MapName string
var GameType string ViewMap bool
var ViewMap bool UseColor bool
var UseColor bool Seed int64
var Seed int64 TurnDelay int
var TurnDelay int DebugRequests bool
var DebugRequests bool Output string
var Output string FoodSpawnChance int
MinimumFood int
HazardDamagePerTurn int
ShrinkEveryNTurns int
var FoodSpawnChance int // Internal game state
var MinimumFood int settings map[string]string
var HazardDamagePerTurn int snakeStates map[string]SnakeState
var ShrinkEveryNTurns int gameID string
httpClient http.Client
var defaultConfig = map[string]string{ ruleset rules.Ruleset
// default to standard ruleset gameMap maps.GameMap
rules.ParamGameType: "standard",
// squad settings default to true (not zero value)
rules.ParamSharedElimination: "true",
rules.ParamSharedHealth: "true",
rules.ParamSharedLength: "true",
rules.ParamAllowBodyCollisions: "true",
} }
var playCmd = &cobra.Command{ func NewPlayCommand() *cobra.Command {
Use: "play", gameState := &GameState{}
Short: "Play a game of Battlesnake locally.",
Long: "Play a game of Battlesnake locally.",
Run: run,
PreRun: playPreRun,
}
func init() { var playCmd = &cobra.Command{
rootCmd.AddCommand(playCmd) 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(&gameState.Width, "width", "W", 11, "Width of Board")
playCmd.Flags().IntVarP(&Height, "height", "H", 11, "Height of Board") playCmd.Flags().IntVarP(&gameState.Height, "height", "H", 11, "Height of Board")
playCmd.Flags().StringArrayVarP(&Names, "name", "n", nil, "Name of Snake") playCmd.Flags().StringArrayVarP(&gameState.Names, "name", "n", nil, "Name of Snake")
playCmd.Flags().StringArrayVarP(&URLs, "url", "u", nil, "URL of Snake") playCmd.Flags().StringArrayVarP(&gameState.URLs, "url", "u", nil, "URL of Snake")
playCmd.Flags().StringArrayVarP(&Names, "squad", "S", nil, "Squad of Snake") playCmd.Flags().IntVarP(&gameState.Timeout, "timeout", "t", 500, "Request Timeout")
playCmd.Flags().IntVarP(&Timeout, "timeout", "t", 500, "Request Timeout") playCmd.Flags().BoolVarP(&gameState.Sequential, "sequential", "s", false, "Use Sequential Processing")
playCmd.Flags().BoolVarP(&Sequential, "sequential", "s", false, "Use Sequential Processing") playCmd.Flags().StringVarP(&gameState.GameType, "gametype", "g", "standard", "Type of Game Rules")
playCmd.Flags().StringVarP(&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(&ViewMap, "viewmap", "v", false, "View the Map Each Turn") playCmd.Flags().BoolVarP(&gameState.ViewMap, "viewmap", "v", false, "View the Map Each Turn")
playCmd.Flags().BoolVarP(&UseColor, "color", "c", false, "Use color to draw the map") playCmd.Flags().BoolVarP(&gameState.UseColor, "color", "c", false, "Use color to draw the map")
playCmd.Flags().Int64VarP(&Seed, "seed", "r", time.Now().UTC().UnixNano(), "Random Seed") playCmd.Flags().Int64VarP(&gameState.Seed, "seed", "r", time.Now().UTC().UnixNano(), "Random Seed")
playCmd.Flags().IntVarP(&TurnDelay, "delay", "d", 0, "Turn Delay in Milliseconds") playCmd.Flags().IntVarP(&gameState.TurnDelay, "delay", "d", 0, "Turn Delay in Milliseconds")
playCmd.Flags().IntVarP(&TurnDuration, "duration", "D", 0, "Minimum Turn Duration in Milliseconds") playCmd.Flags().IntVarP(&gameState.TurnDuration, "duration", "D", 0, "Minimum Turn Duration in Milliseconds")
playCmd.Flags().BoolVar(&DebugRequests, "debug-requests", false, "Log body of all requests sent") playCmd.Flags().BoolVar(&gameState.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().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(&gameState.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(&gameState.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(&gameState.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.ShrinkEveryNTurns, "shrinkEveryNTurns", 25, "In Royale mode, the number of turns between generating new hazards")
playCmd.Flags().SortFlags = false playCmd.Flags().SortFlags = false
return playCmd
} }
func playPreRun(cmd *cobra.Command, args []string) { // Setup a GameState once all the fields have been parsed from the command-line.
initialiseGameConfig() 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) { // Setup and run a full game.
rand.Seed(Seed) func (gameState *GameState) Run() {
gameState.initialize()
GameId = uuid.New().String() // Setup local state for snakes
Turn = 0 gameState.snakeStates = gameState.buildSnakesFromOptions()
var endTime time.Time rand.Seed(gameState.Seed)
snakeStates := buildSnakesFromOptions()
ruleset := getRuleset(Seed, snakeStates) boardState := gameState.initializeBoardFromArgs()
state := initializeBoardFromArgs(ruleset, snakeStates) exportGame := gameState.Output != ""
exportGame := Output != ""
gameExporter := GameExporter{ gameExporter := GameExporter{
game: createClientGame(ruleset), game: gameState.createClientGame(),
snakeRequests: make([]client.SnakeRequest, 0), snakeRequests: make([]client.SnakeRequest, 0),
winner: SnakeState{}, winner: SnakeState{},
isDraw: false, isDraw: false,
} }
for v := false; !v; v, _ = ruleset.IsGameOver(state) { if gameState.ViewMap {
if TurnDuration > 0 { gameState.printMap(boardState)
endTime = time.Now().Add(time.Duration(TurnDuration) * time.Millisecond) }
}
var endTime time.Time
Turn++ for v := false; !v; v, _ = gameState.ruleset.IsGameOver(boardState) {
state = createNextBoardState(ruleset, state, snakeStates, Turn) if gameState.TurnDuration > 0 {
endTime = time.Now().Add(time.Duration(gameState.TurnDuration) * time.Millisecond)
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))
} }
// Export game first, if enabled, so that we save the board on turn zero
if exportGame { if exportGame {
// The output file was designed in a way so that (nearly) every entry is equivalent to a valid API request. // 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. // 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. // 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 // 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. // be adjusted to look like an API call for a specific snake in the game.
snakeState := snakeStates[state.Snakes[0].ID] for _, snakeState := range gameState.snakeStates {
snakeRequest := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset) snakeRequest := gameState.getRequestBodyForSnake(boardState, snakeState)
gameExporter.AddSnakeRequest(snakeRequest) 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 isDraw := true
if GameType == "solo" { if gameState.GameType == "solo" {
log.Printf("[DONE]: Game completed after %v turns.", Turn) log.Printf("[DONE]: Game completed after %v turns.", boardState.Turn)
if exportGame { if exportGame {
// These checks for exportGame are present to avoid vacuuming up RAM when an export is not requred. // 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 { } else {
var winner SnakeState var winner SnakeState
for _, snake := range state.Snakes { for _, snake := range boardState.Snakes {
snakeState := snakeStates[snake.ID] snakeState := gameState.snakeStates[snake.ID]
if snake.EliminatedCause == rules.NotEliminated { if snake.EliminatedCause == rules.NotEliminated {
isDraw = false isDraw = false
winner = snakeState winner = snakeState
} }
sendEndRequest(ruleset, state, snakeState, snakeStates) gameState.sendEndRequest(boardState, snakeState)
} }
if isDraw { 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 { } 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 { if exportGame {
gameExporter.winner = winner gameExporter.winner = winner
@ -192,7 +235,7 @@ var run = func(cmd *cobra.Command, args []string) {
} }
if exportGame { if exportGame {
err := gameExporter.FlushToFile(Output, "JSONL") err := gameExporter.FlushToFile(gameState.Output, "JSONL")
if err != nil { if err != nil {
log.Printf("[WARN]: Unable to export game. Reason: %v\n", err.Error()) log.Printf("[WARN]: Unable to export game. Reason: %v\n", err.Error())
os.Exit(1) os.Exit(1)
@ -200,83 +243,57 @@ var run = func(cmd *cobra.Command, args []string) {
} }
} }
func initialiseGameConfig() { func (gameState *GameState) initializeBoardFromArgs() *rules.BoardState {
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,
}
snakeIds := []string{} snakeIds := []string{}
for _, snakeState := range snakeStates { for _, snakeState := range gameState.snakeStates {
snakeIds = append(snakeIds, snakeState.ID) 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 { 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 { if err != nil {
log.Panic("[PANIC]: Error Initializing Board State") log.Fatalf("Error Initializing Board State: %v", err)
} }
for _, snakeState := range snakeStates { for _, snakeState := range gameState.snakeStates {
snakeRequest := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset) snakeRequest := gameState.getRequestBodyForSnake(boardState, snakeState)
requestBody := serialiseSnakeRequest(snakeRequest) requestBody := serialiseSnakeRequest(snakeRequest)
u, _ := url.ParseRequestURI(snakeState.URL) u, _ := url.ParseRequestURI(snakeState.URL)
u.Path = path.Join(u.Path, "start") u.Path = path.Join(u.Path, "start")
if DebugRequests { if gameState.DebugRequests {
log.Printf("POST %s: %v", u, string(requestBody)) 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 { if err != nil {
log.Printf("[WARN]: Request to %v failed", u.String()) 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 var moves []rules.SnakeMove
if Sequential { if gameState.Sequential {
for _, snakeState := range snakeStates { for _, snakeState := range gameState.snakeStates {
for _, snake := range state.Snakes { for _, snake := range boardState.Snakes {
if snakeState.ID == snake.ID && snake.EliminatedCause == rules.NotEliminated { 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 { } else {
var wg sync.WaitGroup var wg sync.WaitGroup
c := make(chan rules.SnakeMove, len(snakeStates)) c := make(chan rules.SnakeMove, len(gameState.snakeStates))
for _, snakeState := range snakeStates { for _, snakeState := range gameState.snakeStates {
for _, snake := range state.Snakes { for _, snake := range boardState.Snakes {
if snakeState.ID == snake.ID && snake.EliminatedCause == rules.NotEliminated { if snakeState.ID == snake.ID && snake.EliminatedCause == rules.NotEliminated {
wg.Add(1) wg.Add(1)
go func(snakeState SnakeState) { go func(snakeState SnakeState) {
defer wg.Done() defer wg.Done()
c <- getMoveForSnake(ruleset, state, snakeState, snakeStates) c <- gameState.getMoveForSnake(boardState, snakeState)
}(snakeState) }(snakeState)
} }
} }
@ -290,29 +307,34 @@ func createNextBoardState(ruleset rules.Ruleset, state *rules.BoardState, snakeS
} }
} }
for _, move := range moves { for _, move := range moves {
snakeState := snakeStates[move.ID] snakeState := gameState.snakeStates[move.ID]
snakeState.LastMove = move.Move 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 { 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 { func (gameState *GameState) getMoveForSnake(boardState *rules.BoardState, snakeState SnakeState) rules.SnakeMove {
snakeRequest := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset) snakeRequest := gameState.getRequestBodyForSnake(boardState, snakeState)
requestBody := serialiseSnakeRequest(snakeRequest) requestBody := serialiseSnakeRequest(snakeRequest)
u, _ := url.ParseRequestURI(snakeState.URL) u, _ := url.ParseRequestURI(snakeState.URL)
u.Path = path.Join(u.Path, "move") u.Path = path.Join(u.Path, "move")
if DebugRequests { if gameState.DebugRequests {
log.Printf("POST %s: %v", u, string(requestBody)) 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 move := snakeState.LastMove
if err != nil { if err != nil {
log.Printf("[WARN]: Request to %v failed\n", u.String()) 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} return rules.SnakeMove{ID: snakeState.ID, Move: move}
} }
func sendEndRequest(ruleset rules.Ruleset, state *rules.BoardState, snakeState SnakeState, snakeStates map[string]SnakeState) { func (gameState *GameState) sendEndRequest(boardState *rules.BoardState, snakeState SnakeState) {
snakeRequest := getIndividualBoardStateForSnake(state, snakeState, snakeStates, ruleset) snakeRequest := gameState.getRequestBodyForSnake(boardState, snakeState)
requestBody := serialiseSnakeRequest(snakeRequest) requestBody := serialiseSnakeRequest(snakeRequest)
u, _ := url.ParseRequestURI(snakeState.URL) u, _ := url.ParseRequestURI(snakeState.URL)
u.Path = path.Join(u.Path, "end") u.Path = path.Join(u.Path, "end")
if DebugRequests { if gameState.DebugRequests {
log.Printf("POST %s: %v", u, string(requestBody)) 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 { if err != nil {
log.Printf("[WARN]: Request to %v failed", u.String()) 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 var youSnake rules.Snake
for _, snk := range state.Snakes { for _, snk := range boardState.Snakes {
if snakeState.ID == snk.ID { if snakeState.ID == snk.ID {
youSnake = snk youSnake = snk
break break
} }
} }
request := client.SnakeRequest{ request := client.SnakeRequest{
Game: createClientGame(ruleset), Game: gameState.createClientGame(),
Turn: Turn, Turn: boardState.Turn,
Board: convertStateToBoard(state, snakeStates), Board: convertStateToBoard(boardState, gameState.snakeStates),
You: convertRulesSnake(youSnake, snakeStates[youSnake.ID]), You: convertRulesSnake(youSnake, snakeState),
} }
return request return request
} }
func serialiseSnakeRequest(snakeRequest client.SnakeRequest) []byte { func (gameState *GameState) createClientGame() client.Game {
requestJSON, err := json.Marshal(snakeRequest) return client.Game{
if err != nil { ID: gameState.gameID,
log.Panic("[PANIC]: Error Marshalling JSON from State") Timeout: gameState.Timeout,
panic(err) Ruleset: client.Ruleset{
} Name: gameState.ruleset.Name(),
return requestJSON Version: "cli", // TODO: Use GitHub Release Version
} Settings: gameState.ruleset.Settings(),
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,
}, },
Map: gameState.gameMap.ID(),
} }
} }
func convertRulesSnakes(snakes []rules.Snake, snakeStates map[string]SnakeState) []client.Snake { func (gameState *GameState) buildSnakesFromOptions() map[string]SnakeState {
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 {
bodyChars := []rune{'■', '⌀', '●', '☻', '◘', '☺', '□', '⍟'} bodyChars := []rune{'■', '⌀', '●', '☻', '◘', '☺', '□', '⍟'}
var numSnakes int var numSnakes int
snakes := map[string]SnakeState{} snakes := map[string]SnakeState{}
numNames := len(Names) numNames := len(gameState.Names)
numURLs := len(URLs) numURLs := len(gameState.URLs)
numSquads := len(Squads)
if numNames > numURLs { if numNames > numURLs {
numSnakes = numNames numSnakes = numNames
} else { } else {
@ -440,42 +418,33 @@ func buildSnakesFromOptions() map[string]SnakeState {
for i := int(0); i < numSnakes; i++ { for i := int(0); i < numSnakes; i++ {
var snakeName string var snakeName string
var snakeURL string var snakeURL string
var snakeSquad string
id := uuid.New().String() id := uuid.New().String()
if i < numNames { if i < numNames {
snakeName = Names[i] snakeName = gameState.Names[i]
} else { } 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 snakeName = id
} }
if i < numURLs { if i < numURLs {
u, err := url.ParseRequestURI(URLs[i]) u, err := url.ParseRequestURI(gameState.URLs[i])
if err != nil { 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" snakeURL = "https://example.com"
} else { } else {
snakeURL = u.String() snakeURL = u.String()
} }
} else { } 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" 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{ snakeState := SnakeState{
Name: snakeName, URL: snakeURL, ID: id, LastMove: "up", Character: bodyChars[i%8], 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 { if err != nil {
log.Printf("[WARN]: Request to %v failed: %v", snakeURL, err) log.Printf("[WARN]: Request to %v failed: %v", snakeURL, err)
} else if res.Body != nil { } else if res.Body != nil {
@ -495,14 +464,129 @@ func buildSnakesFromOptions() map[string]SnakeState {
snakeState.Color = pingResponse.Color snakeState.Color = pingResponse.Color
} }
} }
if GameType == "squad" {
snakeState.Squad = snakeSquad
}
snakes[snakeState.ID] = snakeState snakes[snakeState.ID] = snakeState
} }
return snakes 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 // Parses a color string like "#ef03d3" to rgb values from 0 to 255 or returns
// the default gray if any errors occure // the default gray if any errors occure
func parseSnakeColor(color string) (int64, int64, int64) { func parseSnakeColor(color string) (int64, int64, int64) {
@ -517,75 +601,3 @@ func parseSnakeColor(color string) (int64, int64, int64) {
// Default gray color from Battlesnake board // Default gray color from Battlesnake board
return 136, 136, 136 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" "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) { func TestGetIndividualBoardStateForSnake(t *testing.T) {
s1 := rules.Snake{ID: "one", Body: []rules.Point{{X: 3, Y: 3}}} s1 := rules.Snake{ID: "one", Body: []rules.Point{{X: 3, Y: 3}}}
s2 := rules.Snake{ID: "two", Body: []rules.Point{{X: 4, 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", Tail: "bolt",
Color: "#654321", Color: "#654321",
} }
snakeStates := map[string]SnakeState{
gameState := buildDefaultGameState()
gameState.initialize()
gameState.gameID = "GAME_ID"
gameState.snakeStates = map[string]SnakeState{
s1State.ID: s1State, s1State.ID: s1State,
s2State.ID: s2State, s2State.ID: s2State,
} }
initialiseGameConfig() // initialise default config
snakeRequest := getIndividualBoardStateForSnake(state, s1State, snakeStates, getRuleset(0, snakeStates)) snakeRequest := gameState.getRequestBodyForSnake(state, s1State)
requestBody := serialiseSnakeRequest(snakeRequest) requestBody := serialiseSnakeRequest(snakeRequest)
test.RequireJSONMatchesFixture(t, "testdata/snake_request_body.json", string(requestBody)) test.RequireJSONMatchesFixture(t, "testdata/snake_request_body.json", string(requestBody))
@ -69,34 +98,25 @@ func TestSettingsRequestSerialization(t *testing.T) {
Tail: "bolt", Tail: "bolt",
Color: "#654321", 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{ for _, gt := range []string{
rules.GameTypeStandard, rules.GameTypeRoyale, rules.GameTypeSolo, rules.GameTypeStandard, rules.GameTypeRoyale, rules.GameTypeSolo,
rules.GameTypeWrapped, rules.GameTypeSquad, rules.GameTypeConstrictor, rules.GameTypeWrapped, rules.GameTypeConstrictor,
} { } {
t.Run(gt, func(t *testing.T) { t.Run(gt, func(t *testing.T) {
// apply game type gameState := buildDefaultGameState()
ruleset := rsb.WithParams(map[string]string{rules.ParamGameType: gt}).Ruleset()
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) requestBody := serialiseSnakeRequest(snakeRequest)
t.Log(string(requestBody)) t.Log(string(requestBody))
@ -128,7 +148,6 @@ func TestConvertRulesSnakes(t *testing.T) {
ID: "one", ID: "one",
Name: "ONE", Name: "ONE",
URL: "http://example1.com", URL: "http://example1.com",
Squad: "squadA",
Head: "a", Head: "a",
Tail: "b", Tail: "b",
Color: "#012345", Color: "#012345",
@ -146,7 +165,6 @@ func TestConvertRulesSnakes(t *testing.T) {
Head: client.Coord{X: 3, Y: 3}, Head: client.Coord{X: 3, Y: 3},
Length: 2, Length: 2,
Shout: "", Shout: "",
Squad: "squadA",
Customizations: client.Customizations{ Customizations: client.Customizations{
Color: "#012345", Color: "#012345",
Head: "a", Head: "a",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -24,7 +24,6 @@ const (
EliminatedByOutOfHealth = "out-of-health" EliminatedByOutOfHealth = "out-of-health"
EliminatedByHeadToHeadCollision = "head-collision" EliminatedByHeadToHeadCollision = "head-collision"
EliminatedByOutOfBounds = "wall-collision" EliminatedByOutOfBounds = "wall-collision"
EliminatedBySquad = "squad-eliminated"
// Error constants // Error constants
ErrorTooManySnakes = RulesetError("too many snakes for fixed start positions") ErrorTooManySnakes = RulesetError("too many snakes for fixed start positions")
@ -41,7 +40,6 @@ const (
GameTypeConstrictor = "constrictor" GameTypeConstrictor = "constrictor"
GameTypeRoyale = "royale" GameTypeRoyale = "royale"
GameTypeSolo = "solo" GameTypeSolo = "solo"
GameTypeSquad = "squad"
GameTypeStandard = "standard" GameTypeStandard = "standard"
GameTypeWrapped = "wrapped" GameTypeWrapped = "wrapped"

View file

@ -19,11 +19,10 @@ func TestSetupBoard_Error(t *testing.T) {
Id: t.Name(), Id: t.Name(),
Error: errors.New("bad map update"), Error: errors.New("bad map update"),
} }
RegisterMap(testMap.ID(), testMap) TestMap(testMap.ID(), testMap, func() {
_, err := SetupBoard(testMap.ID(), rules.Settings{}, 10, 10, []string{})
_, err := SetupBoard(testMap.ID(), rules.Settings{}, 10, 10, []string{}) require.EqualError(t, err, "bad map update")
})
require.EqualError(t, err, "bad map update")
} }
func TestSetupBoard(t *testing.T) { func TestSetupBoard(t *testing.T) {
@ -42,26 +41,27 @@ func TestSetupBoard(t *testing.T) {
{X: 2, Y: 2}, {X: 2, Y: 2},
}, },
} }
RegisterMap(testMap.ID(), testMap)
boardState, err := SetupBoard(testMap.ID(), rules.Settings{}, 10, 10, []string{"1", "2"}) TestMap(testMap.ID(), testMap, func() {
boardState, err := SetupBoard(testMap.ID(), rules.Settings{}, 10, 10, []string{"1", "2"})
require.NoError(t, err) require.NoError(t, err)
require.Len(t, boardState.Snakes, 2) require.Len(t, boardState.Snakes, 2)
require.Equal(t, rules.Snake{ require.Equal(t, rules.Snake{
ID: "1", ID: "1",
Body: []rules.Point{{X: 3, Y: 4}, {X: 3, Y: 4}, {X: 3, Y: 4}}, Body: []rules.Point{{X: 3, Y: 4}, {X: 3, Y: 4}, {X: 3, Y: 4}},
Health: rules.SnakeMaxHealth, Health: rules.SnakeMaxHealth,
}, boardState.Snakes[0]) }, boardState.Snakes[0])
require.Equal(t, rules.Snake{ require.Equal(t, rules.Snake{
ID: "2", ID: "2",
Body: []rules.Point{{X: 6, Y: 2}, {X: 6, Y: 2}, {X: 6, Y: 2}}, Body: []rules.Point{{X: 6, Y: 2}, {X: 6, Y: 2}, {X: 6, Y: 2}},
Health: rules.SnakeMaxHealth, Health: rules.SnakeMaxHealth,
}, boardState.Snakes[1]) }, boardState.Snakes[1])
require.Equal(t, []rules.Point{{X: 1, Y: 1}, {X: 5, Y: 3}}, boardState.Food) require.Equal(t, []rules.Point{{X: 1, Y: 1}, {X: 5, Y: 3}}, boardState.Food)
require.Equal(t, []rules.Point{{X: 3, Y: 5}, {X: 2, Y: 2}}, boardState.Hazards) require.Equal(t, []rules.Point{{X: 3, Y: 5}, {X: 2, Y: 2}}, boardState.Hazards)
})
} }
func TestUpdateBoard(t *testing.T) { func TestUpdateBoard(t *testing.T) {
@ -80,7 +80,6 @@ func TestUpdateBoard(t *testing.T) {
{X: 2, Y: 2}, {X: 2, Y: 2},
}, },
} }
RegisterMap(testMap.ID(), testMap)
previousBoardState := &rules.BoardState{ previousBoardState := &rules.BoardState{
Turn: 0, Turn: 0,
@ -98,17 +97,20 @@ func TestUpdateBoard(t *testing.T) {
}, },
}, },
} }
boardState, err := UpdateBoard(testMap.ID(), previousBoardState, rules.Settings{})
require.NoError(t, err) TestMap(testMap.ID(), testMap, func() {
boardState, err := UpdateBoard(testMap.ID(), previousBoardState, rules.Settings{})
require.Len(t, boardState.Snakes, 1) require.NoError(t, err)
require.Equal(t, rules.Snake{ require.Len(t, boardState.Snakes, 1)
ID: "1",
Body: []rules.Point{{X: 6, Y: 4}, {X: 6, Y: 3}, {X: 6, Y: 2}}, require.Equal(t, rules.Snake{
Health: rules.SnakeMaxHealth, ID: "1",
}, boardState.Snakes[0]) Body: []rules.Point{{X: 6, Y: 4}, {X: 6, Y: 3}, {X: 6, Y: 2}},
require.Equal(t, []rules.Point{{X: 0, Y: 1}, {X: 1, Y: 1}, {X: 5, Y: 3}}, boardState.Food) Health: rules.SnakeMaxHealth,
require.Equal(t, []rules.Point{{X: 3, Y: 4}, {X: 3, Y: 5}, {X: 2, Y: 2}}, boardState.Hazards) }, boardState.Snakes[0])
require.Equal(t, []rules.Point{{X: 0, Y: 1}, {X: 1, Y: 1}, {X: 5, Y: 3}}, boardState.Food)
require.Equal(t, []rules.Point{{X: 3, Y: 4}, {X: 3, Y: 5}, {X: 2, Y: 2}}, boardState.Hazards)
})
} }

View file

@ -38,3 +38,9 @@ func GetMap(id string) (GameMap, error) {
func RegisterMap(id string, m GameMap) { func RegisterMap(id string, m GameMap) {
globalRegistry.RegisterMap(id, m) globalRegistry.RegisterMap(id, m)
} }
func TestMap(id string, m GameMap, callback func()) {
globalRegistry[id] = m
callback()
delete(globalRegistry, id)
}

68
maps/registry_test.go Normal file
View file

@ -0,0 +1,68 @@
package maps
import (
"testing"
"github.com/BattlesnakeOfficial/rules"
"github.com/stretchr/testify/require"
)
const maxBoardWidth, maxBoardHeight = 25, 25
var testSettings rules.Settings = rules.Settings{
FoodSpawnChance: 25,
MinimumFood: 1,
HazardDamagePerTurn: 14,
RoyaleSettings: rules.RoyaleSettings{
ShrinkEveryNTurns: 1,
},
}
func TestRegisteredMaps(t *testing.T) {
for mapName, gameMap := range globalRegistry {
t.Run(mapName, func(t *testing.T) {
require.Equalf(t, mapName, gameMap.ID(), "%#v game map doesn't return its own ID", mapName)
var setupBoardState *rules.BoardState
for width := 0; width < maxBoardWidth; width++ {
for height := 0; height < maxBoardHeight; height++ {
initialBoardState := rules.NewBoardState(width, height)
initialBoardState.Snakes = append(initialBoardState.Snakes, rules.Snake{ID: "1", Body: []rules.Point{}})
initialBoardState.Snakes = append(initialBoardState.Snakes, rules.Snake{ID: "2", Body: []rules.Point{}})
passedBoardState := initialBoardState.Clone()
tempBoardState := initialBoardState.Clone()
err := gameMap.SetupBoard(passedBoardState, testSettings, NewBoardStateEditor(tempBoardState))
if err == nil {
setupBoardState = tempBoardState
require.Equal(t, initialBoardState, passedBoardState, "BoardState should not be modified directly by GameMap.SetupBoard")
break
}
}
}
require.NotNil(t, setupBoardState, "Map does not successfully setup the board at any supported combination of width and height")
require.NotNil(t, setupBoardState.Food)
require.NotNil(t, setupBoardState.Hazards)
require.NotNil(t, setupBoardState.Snakes)
for _, snake := range setupBoardState.Snakes {
require.NotEmpty(t, snake.Body, "Map should place all snakes by initializing their body")
}
previousBoardState := rules.NewBoardState(rules.BoardSizeMedium, rules.BoardSizeMedium)
previousBoardState.Food = append(previousBoardState.Food, []rules.Point{{X: 1, Y: 2}, {X: 3, Y: 4}}...)
previousBoardState.Hazards = append(previousBoardState.Food, []rules.Point{{X: 4, Y: 3}, {X: 2, Y: 1}}...)
previousBoardState.Snakes = append(previousBoardState.Snakes, rules.Snake{
ID: "1",
Body: []rules.Point{{X: 5, Y: 5}, {X: 5, Y: 4}, {X: 5, Y: 3}},
Health: 100,
})
previousBoardState.Turn = 0
passedBoardState := previousBoardState.Clone()
tempBoardState := previousBoardState.Clone()
err := gameMap.UpdateBoard(passedBoardState, testSettings, NewBoardStateEditor(tempBoardState))
require.NoError(t, err, "GameMap.UpdateBoard returned an error")
require.Equal(t, previousBoardState, passedBoardState, "BoardState should not be modified directly by GameMap.UpdateBoard")
})
}
}

View file

@ -29,6 +29,11 @@ func (m RoyaleHazardsMap) SetupBoard(lastBoardState *rules.BoardState, settings
} }
func (m RoyaleHazardsMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { func (m RoyaleHazardsMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
// Use StandardMap to populate food
if err := (StandardMap{}).UpdateBoard(lastBoardState, settings, editor); err != nil {
return err
}
// Royale uses the current turn to generate hazards, not the previous turn that's in the board state // Royale uses the current turn to generate hazards, not the previous turn that's in the board state
turn := lastBoardState.Turn + 1 turn := lastBoardState.Turn + 1

View file

@ -11,14 +11,12 @@ const (
StageHazardDamageStandard = "hazard_damage.standard" StageHazardDamageStandard = "hazard_damage.standard"
StageEliminationStandard = "elimination.standard" StageEliminationStandard = "elimination.standard"
StageGameOverSoloSnake = "game_over.solo_snake" StageGameOverSoloSnake = "game_over.solo_snake"
StageGameOverBySquad = "game_over.by_squad" StageSpawnFoodNoFood = "spawn_food.no_food"
StageSpawnFoodNoFood = "spawn_food.no_food" StageSpawnHazardsShrinkMap = "spawn_hazards.shrink_map"
StageSpawnHazardsShrinkMap = "spawn_hazards.shrink_map" StageModifySnakesAlwaysGrow = "modify_snakes.always_grow"
StageEliminationResurrectSquadCollisions = "elimination.resurrect_squad_collisions" StageMovementWrapBoundaries = "movement.wrap_boundaries"
StageModifySnakesAlwaysGrow = "modify_snakes.always_grow" StageModifySnakesShareAttributes = "modify_snakes.share_attributes"
StageMovementWrapBoundaries = "movement.wrap_boundaries"
StageModifySnakesShareAttributes = "modify_snakes.share_attributes"
) )
// globalRegistry is a global, default mapping of stage names to stage functions. // globalRegistry is a global, default mapping of stage names to stage functions.
@ -26,21 +24,18 @@ const (
// Plugins that wish to extend the available game stages should call RegisterPipelineStageError // Plugins that wish to extend the available game stages should call RegisterPipelineStageError
// to add additional stages. // to add additional stages.
var globalRegistry = StageRegistry{ var globalRegistry = StageRegistry{
StageSpawnFoodNoFood: RemoveFoodConstrictor, StageSpawnFoodNoFood: RemoveFoodConstrictor,
StageSpawnFoodStandard: SpawnFoodStandard, StageSpawnFoodStandard: SpawnFoodStandard,
StageGameOverSoloSnake: GameOverSolo, StageGameOverSoloSnake: GameOverSolo,
StageGameOverBySquad: GameOverSquad, StageGameOverStandard: GameOverStandard,
StageGameOverStandard: GameOverStandard, StageHazardDamageStandard: DamageHazardsStandard,
StageHazardDamageStandard: DamageHazardsStandard, StageSpawnHazardsShrinkMap: PopulateHazardsRoyale,
StageSpawnHazardsShrinkMap: PopulateHazardsRoyale, StageStarvationStandard: ReduceSnakeHealthStandard,
StageStarvationStandard: ReduceSnakeHealthStandard, StageFeedSnakesStandard: FeedSnakesStandard,
StageEliminationResurrectSquadCollisions: ResurrectSnakesSquad, StageEliminationStandard: EliminateSnakesStandard,
StageFeedSnakesStandard: FeedSnakesStandard, StageModifySnakesAlwaysGrow: GrowSnakesConstrictor,
StageEliminationStandard: EliminateSnakesStandard, StageMovementStandard: MoveSnakesStandard,
StageModifySnakesAlwaysGrow: GrowSnakesConstrictor, StageMovementWrapBoundaries: MoveSnakesWrapped,
StageMovementStandard: MoveSnakesStandard,
StageMovementWrapBoundaries: MoveSnakesWrapped,
StageModifySnakesShareAttributes: ShareAttributesSquad,
} }
// StageFunc represents a single stage of an ordered pipeline and applies custom logic to the board state each turn. // StageFunc represents a single stage of an ordered pipeline and applies custom logic to the board state each turn.

View file

@ -2,7 +2,6 @@ package rules
import ( import (
"errors" "errors"
"math/rand"
) )
var royaleRulesetStages = []string{ var royaleRulesetStages = []string{
@ -10,7 +9,6 @@ var royaleRulesetStages = []string{
StageStarvationStandard, StageStarvationStandard,
StageHazardDamageStandard, StageHazardDamageStandard,
StageFeedSnakesStandard, StageFeedSnakesStandard,
StageSpawnFoodStandard,
StageEliminationStandard, StageEliminationStandard,
StageSpawnHazardsShrinkMap, StageSpawnHazardsShrinkMap,
StageGameOverStandard, StageGameOverStandard,
@ -19,8 +17,6 @@ var royaleRulesetStages = []string{
type RoyaleRuleset struct { type RoyaleRuleset struct {
StandardRuleset StandardRuleset
Seed int64
ShrinkEveryNTurns int ShrinkEveryNTurns int
} }
@ -55,7 +51,7 @@ func PopulateHazardsRoyale(b *BoardState, settings Settings, moves []SnakeMove)
return false, nil return false, nil
} }
randGenerator := rand.New(rand.NewSource(settings.RoyaleSettings.seed)) randGenerator := settings.GetRand(0)
numShrinks := turn / settings.RoyaleSettings.ShrinkEveryNTurns numShrinks := turn / settings.RoyaleSettings.ShrinkEveryNTurns
minX, maxX := 0, b.Width-1 minX, maxX := 0, b.Width-1
@ -91,7 +87,6 @@ func (r *RoyaleRuleset) IsGameOver(b *BoardState) (bool, error) {
func (r RoyaleRuleset) Settings() Settings { func (r RoyaleRuleset) Settings() Settings {
s := r.StandardRuleset.Settings() s := r.StandardRuleset.Settings()
s.RoyaleSettings = RoyaleSettings{ s.RoyaleSettings = RoyaleSettings{
seed: r.Seed,
ShrinkEveryNTurns: r.ShrinkEveryNTurns, ShrinkEveryNTurns: r.ShrinkEveryNTurns,
} }
return s return s

View file

@ -94,15 +94,14 @@ func TestRoyaleHazards(t *testing.T) {
Width: test.Width, Width: test.Width,
Height: test.Height, Height: test.Height,
} }
r := RoyaleRuleset{ settings := Settings{
StandardRuleset: StandardRuleset{ HazardDamagePerTurn: 1,
HazardDamagePerTurn: 1, RoyaleSettings: RoyaleSettings{
ShrinkEveryNTurns: test.ShrinkEveryNTurns,
}, },
Seed: seed, }.WithSeed(seed)
ShrinkEveryNTurns: test.ShrinkEveryNTurns,
}
_, err := PopulateHazardsRoyale(b, r.Settings(), mockSnakeMoves()) _, err := PopulateHazardsRoyale(b, settings, mockSnakeMoves())
require.Equal(t, test.Error, err) require.Equal(t, test.Error, err)
if err == nil { if err == nil {
// Obstacles should match // Obstacles should match
@ -121,64 +120,6 @@ func TestRoyaleHazards(t *testing.T) {
} }
} }
func TestRoyalDamageNextTurn(t *testing.T) {
seed := int64(45897034512311)
base := &BoardState{Width: 10, Height: 10, Snakes: []Snake{{ID: "one", Health: 100, Body: []Point{{9, 1}, {9, 1}, {9, 1}}}}}
r := RoyaleRuleset{StandardRuleset: StandardRuleset{HazardDamagePerTurn: 30}, Seed: seed, ShrinkEveryNTurns: 10}
m := []SnakeMove{{ID: "one", Move: "down"}}
stateAfterTurn := func(prevState *BoardState, turn int) *BoardState {
nextState := prevState.Clone()
nextState.Turn = turn - 1
_, err := PopulateHazardsRoyale(nextState, r.Settings(), nil)
require.NoError(t, err)
nextState.Turn = turn
return nextState
}
prevState := stateAfterTurn(base, 9)
next, err := r.CreateNextBoardState(prevState, m)
require.NoError(t, err)
require.Equal(t, NotEliminated, next.Snakes[0].EliminatedCause)
require.Equal(t, 99, next.Snakes[0].Health)
require.Equal(t, Point{9, 0}, next.Snakes[0].Body[0])
require.Equal(t, 10, len(next.Hazards)) // X = 0
prevState = stateAfterTurn(base, 19)
next, err = r.CreateNextBoardState(prevState, m)
require.NoError(t, err)
require.Equal(t, NotEliminated, next.Snakes[0].EliminatedCause)
require.Equal(t, 99, next.Snakes[0].Health)
require.Equal(t, Point{9, 0}, next.Snakes[0].Body[0])
require.Equal(t, 20, len(next.Hazards)) // X = 9
prevState = stateAfterTurn(base, 20)
next, err = r.CreateNextBoardState(prevState, m)
require.NoError(t, err)
require.Equal(t, NotEliminated, next.Snakes[0].EliminatedCause)
require.Equal(t, 69, next.Snakes[0].Health)
require.Equal(t, Point{9, 0}, next.Snakes[0].Body[0])
require.Equal(t, 20, len(next.Hazards))
prevState.Snakes[0].Health = 15
next, err = r.CreateNextBoardState(prevState, m)
require.NoError(t, err)
require.Equal(t, EliminatedByOutOfHealth, next.Snakes[0].EliminatedCause)
require.Equal(t, 0, next.Snakes[0].Health)
require.Equal(t, Point{9, 0}, next.Snakes[0].Body[0])
require.Equal(t, 20, len(next.Hazards))
prevState.Food = append(prevState.Food, Point{9, 0})
next, err = r.CreateNextBoardState(prevState, m)
require.NoError(t, err)
require.Equal(t, Point{9, 0}, next.Snakes[0].Body[0])
require.Equal(t, NotEliminated, next.Snakes[0].EliminatedCause)
require.Equal(t, 100, next.Snakes[0].Health)
require.Equal(t, Point{9, 0}, next.Snakes[0].Body[0])
require.Equal(t, 20, len(next.Hazards))
}
// Checks that hazards get placed // Checks that hazards get placed
// also that: // also that:
// - snakes move properly // - snakes move properly
@ -264,13 +205,13 @@ func TestRoyaleCreateNextBoardState(t *testing.T) {
}, },
ShrinkEveryNTurns: 1, ShrinkEveryNTurns: 1,
} }
rand.Seed(0)
rb := NewRulesetBuilder().WithParams(map[string]string{ rb := NewRulesetBuilder().WithParams(map[string]string{
ParamGameType: GameTypeRoyale, ParamGameType: GameTypeRoyale,
ParamHazardDamagePerTurn: "1", ParamHazardDamagePerTurn: "1",
ParamShrinkEveryNTurns: "1", ParamShrinkEveryNTurns: "1",
}) }).WithSeed(1234)
for _, gc := range cases { for _, gc := range cases {
rand.Seed(1234)
gc.requireValidNextState(t, &r) gc.requireValidNextState(t, &r)
// also test a RulesBuilder constructed instance // also test a RulesBuilder constructed instance
gc.requireValidNextState(t, rb.Ruleset()) gc.requireValidNextState(t, rb.Ruleset())

View file

@ -27,7 +27,7 @@ type Settings struct {
HazardMap string `json:"hazardMap"` HazardMap string `json:"hazardMap"`
HazardMapAuthor string `json:"hazardMapAuthor"` HazardMapAuthor string `json:"hazardMapAuthor"`
RoyaleSettings RoyaleSettings `json:"royale"` RoyaleSettings RoyaleSettings `json:"royale"`
SquadSettings SquadSettings `json:"squad"` SquadSettings SquadSettings `json:"squad"` // Deprecated, provided with default fields for API compatibility
rand Rand rand Rand
seed int64 seed int64
@ -41,7 +41,7 @@ func (settings Settings) GetRand(turn int) Rand {
} }
if settings.seed != 0 { if settings.seed != 0 {
return NewSeedRand(settings.seed + int64(turn+1)) return NewSeedRand(settings.seed + int64(turn))
} }
// Default to global random number generator if neither seed or rand are set. // Default to global random number generator if neither seed or rand are set.
@ -64,13 +64,11 @@ func (settings Settings) WithSeed(seed int64) Settings {
// RoyaleSettings contains settings that are specific to the "royale" game mode // RoyaleSettings contains settings that are specific to the "royale" game mode
type RoyaleSettings struct { type RoyaleSettings struct {
seed int64
ShrinkEveryNTurns int `json:"shrinkEveryNTurns"` ShrinkEveryNTurns int `json:"shrinkEveryNTurns"`
} }
// SquadSettings contains settings that are specific to the "squad" game mode // SquadSettings contains settings that are specific to the "squad" game mode
type SquadSettings struct { type SquadSettings struct {
squadMap map[string]string
AllowBodyCollisions bool `json:"allowBodyCollisions"` AllowBodyCollisions bool `json:"allowBodyCollisions"`
SharedElimination bool `json:"sharedElimination"` SharedElimination bool `json:"sharedElimination"`
SharedHealth bool `json:"sharedHealth"` SharedHealth bool `json:"sharedHealth"`
@ -81,14 +79,12 @@ type rulesetBuilder struct {
params map[string]string // game customisation parameters params map[string]string // game customisation parameters
seed int64 // used for random events in games seed int64 // used for random events in games
rand Rand // used for random number generation rand Rand // used for random number generation
squads map[string]string // Snake ID -> Squad Name
} }
// NewRulesetBuilder returns an instance of a builder for the Ruleset types. // NewRulesetBuilder returns an instance of a builder for the Ruleset types.
func NewRulesetBuilder() *rulesetBuilder { func NewRulesetBuilder() *rulesetBuilder {
return &rulesetBuilder{ return &rulesetBuilder{
params: map[string]string{}, params: map[string]string{},
squads: map[string]string{},
} }
} }
@ -122,66 +118,27 @@ func (rb *rulesetBuilder) WithRand(rand Rand) *rulesetBuilder {
return rb return rb
} }
// AddSnakeToSquad adds the specified snake (by ID) to a squad with the given name.
// This configuration may be ignored by game modes if they do not support squads.
func (rb *rulesetBuilder) AddSnakeToSquad(snakeID, squadName string) *rulesetBuilder {
rb.squads[snakeID] = squadName
return rb
}
// Ruleset constructs a customised ruleset using the parameters passed to the builder. // Ruleset constructs a customised ruleset using the parameters passed to the builder.
func (rb rulesetBuilder) Ruleset() PipelineRuleset { func (rb rulesetBuilder) Ruleset() PipelineRuleset {
standardRuleset := &StandardRuleset{
FoodSpawnChance: paramsInt(rb.params, ParamFoodSpawnChance, 0),
MinimumFood: paramsInt(rb.params, ParamMinimumFood, 0),
HazardDamagePerTurn: paramsInt(rb.params, ParamHazardDamagePerTurn, 0),
HazardMap: rb.params[ParamHazardMap],
HazardMapAuthor: rb.params[ParamHazardMapAuthor],
}
name, ok := rb.params[ParamGameType] name, ok := rb.params[ParamGameType]
if !ok { if !ok {
return standardRuleset name = GameTypeStandard
} }
switch name { switch name {
case GameTypeStandard:
return rb.PipelineRuleset(name, NewPipeline(standardRulesetStages...))
case GameTypeConstrictor: case GameTypeConstrictor:
return &ConstrictorRuleset{ return rb.PipelineRuleset(name, NewPipeline(constrictorRulesetStages...))
StandardRuleset: *standardRuleset,
}
case GameTypeRoyale: case GameTypeRoyale:
return &RoyaleRuleset{ return rb.PipelineRuleset(name, NewPipeline(royaleRulesetStages...))
StandardRuleset: *standardRuleset,
Seed: rb.seed,
ShrinkEveryNTurns: paramsInt(rb.params, ParamShrinkEveryNTurns, 0),
}
case GameTypeSolo: case GameTypeSolo:
return &SoloRuleset{ return rb.PipelineRuleset(name, NewPipeline(soloRulesetStages...))
StandardRuleset: *standardRuleset,
}
case GameTypeWrapped: case GameTypeWrapped:
return &WrappedRuleset{ return rb.PipelineRuleset(name, NewPipeline(wrappedRulesetStages...))
StandardRuleset: *standardRuleset, default:
} return rb.PipelineRuleset(name, NewPipeline(standardRulesetStages...))
case GameTypeSquad:
return &SquadRuleset{
StandardRuleset: *standardRuleset,
SquadMap: rb.squadMap(),
AllowBodyCollisions: paramsBool(rb.params, ParamAllowBodyCollisions, false),
SharedElimination: paramsBool(rb.params, ParamSharedElimination, false),
SharedHealth: paramsBool(rb.params, ParamSharedHealth, false),
SharedLength: paramsBool(rb.params, ParamSharedLength, false),
}
} }
return standardRuleset
}
func (rb rulesetBuilder) squadMap() map[string]string {
squadMap := map[string]string{}
for id, squad := range rb.squads {
squadMap[id] = squad
}
return squadMap
} }
// PipelineRuleset provides an implementation of the Ruleset using a pipeline with a name. // PipelineRuleset provides an implementation of the Ruleset using a pipeline with a name.
@ -198,16 +155,8 @@ func (rb rulesetBuilder) PipelineRuleset(name string, p Pipeline) PipelineRulese
HazardMap: rb.params[ParamHazardMap], HazardMap: rb.params[ParamHazardMap],
HazardMapAuthor: rb.params[ParamHazardMapAuthor], HazardMapAuthor: rb.params[ParamHazardMapAuthor],
RoyaleSettings: RoyaleSettings{ RoyaleSettings: RoyaleSettings{
seed: rb.seed,
ShrinkEveryNTurns: paramsInt(rb.params, ParamShrinkEveryNTurns, 0), ShrinkEveryNTurns: paramsInt(rb.params, ParamShrinkEveryNTurns, 0),
}, },
SquadSettings: SquadSettings{
squadMap: rb.squadMap(),
AllowBodyCollisions: paramsBool(rb.params, ParamAllowBodyCollisions, false),
SharedElimination: paramsBool(rb.params, ParamSharedElimination, false),
SharedHealth: paramsBool(rb.params, ParamSharedHealth, false),
SharedLength: paramsBool(rb.params, ParamSharedLength, false),
},
rand: rb.rand, rand: rb.rand,
seed: rb.seed, seed: rb.seed,
}, },

View file

@ -41,30 +41,11 @@ func TestRulesetError(t *testing.T) {
} }
func TestRulesetBuilderInternals(t *testing.T) { func TestRulesetBuilderInternals(t *testing.T) {
// test Royale with seed // test Royale with seed
rsb := NewRulesetBuilder().WithSeed(3).WithParams(map[string]string{ParamGameType: GameTypeRoyale}) rsb := NewRulesetBuilder().WithSeed(3).WithParams(map[string]string{ParamGameType: GameTypeRoyale})
require.Equal(t, int64(3), rsb.seed) require.Equal(t, int64(3), rsb.seed)
require.Equal(t, GameTypeRoyale, rsb.Ruleset().Name()) require.Equal(t, GameTypeRoyale, rsb.Ruleset().Name())
require.Equal(t, int64(3), rsb.Ruleset().(*RoyaleRuleset).Seed) require.Equal(t, int64(3), rsb.Ruleset().Settings().Seed())
// test squad configuration
rsb = NewRulesetBuilder().
WithParams(map[string]string{
ParamGameType: GameTypeSquad,
}).
AddSnakeToSquad("snek1", "squad1").
AddSnakeToSquad("snek2", "squad1").
AddSnakeToSquad("snek3", "squad2").
AddSnakeToSquad("snek4", "squad2")
require.NotNil(t, rsb.Ruleset())
require.Equal(t, GameTypeSquad, rsb.Ruleset().Name())
require.Equal(t, 4, len(rsb.squads))
require.Equal(t, "squad1", rsb.Ruleset().(*SquadRuleset).SquadMap["snek1"])
require.Equal(t, "squad1", rsb.Ruleset().(*SquadRuleset).SquadMap["snek2"])
require.Equal(t, "squad2", rsb.Ruleset().(*SquadRuleset).SquadMap["snek3"])
require.Equal(t, "squad2", rsb.Ruleset().(*SquadRuleset).SquadMap["snek4"])
// test parameter merging // test parameter merging
rsb = NewRulesetBuilder(). rsb = NewRulesetBuilder().

View file

@ -59,7 +59,6 @@ func TestSoloRulesetSettings(t *testing.T) {
func TestRoyaleRulesetSettings(t *testing.T) { func TestRoyaleRulesetSettings(t *testing.T) {
ruleset := rules.RoyaleRuleset{ ruleset := rules.RoyaleRuleset{
Seed: 30,
ShrinkEveryNTurns: 12, ShrinkEveryNTurns: 12,
StandardRuleset: rules.StandardRuleset{ StandardRuleset: rules.StandardRuleset{
MinimumFood: 5, MinimumFood: 5,
@ -94,32 +93,6 @@ func TestConstrictorRulesetSettings(t *testing.T) {
assert.Equal(t, ruleset.HazardMapAuthor, ruleset.Settings().HazardMapAuthor) assert.Equal(t, ruleset.HazardMapAuthor, ruleset.Settings().HazardMapAuthor)
} }
func TestSquadRulesetSettings(t *testing.T) {
ruleset := rules.SquadRuleset{
AllowBodyCollisions: true,
SharedElimination: false,
SharedHealth: true,
SharedLength: false,
StandardRuleset: rules.StandardRuleset{
MinimumFood: 5,
FoodSpawnChance: 10,
HazardDamagePerTurn: 10,
HazardMap: "hz_spiral",
HazardMapAuthor: "altersaddle",
},
}
assert.Equal(t, ruleset.AllowBodyCollisions, ruleset.Settings().SquadSettings.AllowBodyCollisions)
assert.Equal(t, ruleset.SharedElimination, ruleset.Settings().SquadSettings.SharedElimination)
assert.Equal(t, ruleset.SharedHealth, ruleset.Settings().SquadSettings.SharedHealth)
assert.Equal(t, ruleset.SharedLength, ruleset.Settings().SquadSettings.SharedLength)
assert.Equal(t, ruleset.MinimumFood, ruleset.Settings().MinimumFood)
assert.Equal(t, ruleset.FoodSpawnChance, ruleset.Settings().FoodSpawnChance)
assert.Equal(t, ruleset.HazardDamagePerTurn, ruleset.Settings().HazardDamagePerTurn)
assert.Equal(t, ruleset.HazardMap, ruleset.Settings().HazardMap)
assert.Equal(t, ruleset.HazardMapAuthor, ruleset.Settings().HazardMapAuthor)
}
func TestRulesetBuilder(t *testing.T) { func TestRulesetBuilder(t *testing.T) {
// Test that a fresh instance can produce a Ruleset // Test that a fresh instance can produce a Ruleset
require.NotNil(t, rules.NewRulesetBuilder().Ruleset()) require.NotNil(t, rules.NewRulesetBuilder().Ruleset())
@ -131,22 +104,11 @@ func TestRulesetBuilder(t *testing.T) {
// make sure it works okay for lots of game types // make sure it works okay for lots of game types
expectedResults := []struct { expectedResults := []struct {
GameType string GameType string
Snakes map[string]string
}{ }{
{GameType: rules.GameTypeStandard}, {GameType: rules.GameTypeStandard},
{GameType: rules.GameTypeWrapped}, {GameType: rules.GameTypeWrapped},
{GameType: rules.GameTypeRoyale}, {GameType: rules.GameTypeRoyale},
{GameType: rules.GameTypeSolo}, {GameType: rules.GameTypeSolo},
{GameType: rules.GameTypeSquad, Snakes: map[string]string{
"one": "s1",
"two": "s1",
"three": "s2",
"four": "s2",
"five": "s3",
"six": "s3",
"seven": "s4",
"eight": "s4",
}},
{GameType: rules.GameTypeConstrictor}, {GameType: rules.GameTypeConstrictor},
} }
@ -164,11 +126,6 @@ func TestRulesetBuilder(t *testing.T) {
rules.ParamHazardMapAuthor: "tester", rules.ParamHazardMapAuthor: "tester",
}) })
// add any snake squads
for id, squad := range expected.Snakes {
rsb = rsb.AddSnakeToSquad(id, squad)
}
require.NotNil(t, rsb.Ruleset()) require.NotNil(t, rsb.Ruleset())
require.Equal(t, expected.GameType, rsb.Ruleset().Name()) require.Equal(t, expected.GameType, rsb.Ruleset().Name())
// All the standard settings should always be copied over // All the standard settings should always be copied over
@ -200,8 +157,8 @@ func TestRulesetBuilderGetRand(t *testing.T) {
rand1 := ruleset.Settings().GetRand(turn) rand1 := ruleset.Settings().GetRand(turn)
// Should produce a predictable series of numbers based on a seed // Should produce a predictable series of numbers based on a seed
require.Equal(t, 80, rand1.Intn(100)) require.Equal(t, 83, rand1.Intn(100))
require.Equal(t, 94, rand1.Intn(100)) require.Equal(t, 15, rand1.Intn(100))
// Should produce the same number if re-initialized // Should produce the same number if re-initialized
require.Equal( require.Equal(
@ -211,6 +168,6 @@ func TestRulesetBuilderGetRand(t *testing.T) {
) )
// Should produce a different series of numbers for another turn // Should produce a different series of numbers for another turn
require.Equal(t, 22, rand1.Intn(100)) require.Equal(t, 69, rand1.Intn(100))
require.Equal(t, 16, rand1.Intn(100)) require.Equal(t, 86, rand1.Intn(100))
} }

View file

@ -5,7 +5,6 @@ var soloRulesetStages = []string{
StageStarvationStandard, StageStarvationStandard,
StageHazardDamageStandard, StageHazardDamageStandard,
StageFeedSnakesStandard, StageFeedSnakesStandard,
StageSpawnFoodStandard,
StageEliminationStandard, StageEliminationStandard,
StageGameOverSoloSnake, StageGameOverSoloSnake,
} }

152
squad.go
View file

@ -1,152 +0,0 @@
package rules
import (
"errors"
)
var squadRulesetStages = []string{
StageMovementStandard,
StageStarvationStandard,
StageHazardDamageStandard,
StageFeedSnakesStandard,
StageSpawnFoodStandard,
StageEliminationStandard,
StageEliminationResurrectSquadCollisions,
StageModifySnakesShareAttributes,
StageGameOverBySquad,
}
type SquadRuleset struct {
StandardRuleset
SquadMap map[string]string
// These are intentionally designed so that they default to a standard game.
AllowBodyCollisions bool
SharedElimination bool
SharedHealth bool
SharedLength bool
}
func (r *SquadRuleset) Name() string { return GameTypeSquad }
func (r SquadRuleset) Execute(bs *BoardState, s Settings, sm []SnakeMove) (bool, *BoardState, error) {
return NewPipeline(squadRulesetStages...).Execute(bs, s, sm)
}
func (r *SquadRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) {
_, nextState, err := r.Execute(prevState, r.Settings(), moves)
return nextState, err
}
func areSnakesOnSameSquad(squadMap map[string]string, snake *Snake, other *Snake) bool {
return areSnakeIDsOnSameSquad(squadMap, snake.ID, other.ID)
}
func areSnakeIDsOnSameSquad(squadMap map[string]string, snakeID string, otherID string) bool {
return squadMap[snakeID] == squadMap[otherID]
}
func ResurrectSnakesSquad(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
if IsInitialization(b, settings, moves) {
return false, nil
}
if !settings.SquadSettings.AllowBodyCollisions {
return false, nil
}
for i := 0; i < len(b.Snakes); i++ {
snake := &b.Snakes[i]
if snake.EliminatedCause == EliminatedByCollision {
if snake.EliminatedBy == "" {
return false, errors.New("snake eliminated by collision and eliminatedby is not set")
}
if snake.ID != snake.EliminatedBy && areSnakeIDsOnSameSquad(settings.SquadSettings.squadMap, snake.ID, snake.EliminatedBy) {
snake.EliminatedCause = NotEliminated
snake.EliminatedBy = ""
}
}
}
return false, nil
}
func ShareAttributesSquad(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
if IsInitialization(b, settings, moves) {
return false, nil
}
squadSettings := settings.SquadSettings
if !(squadSettings.SharedElimination || squadSettings.SharedLength || squadSettings.SharedHealth) {
return false, nil
}
for i := 0; i < len(b.Snakes); i++ {
snake := &b.Snakes[i]
if snake.EliminatedCause != NotEliminated {
continue
}
for j := 0; j < len(b.Snakes); j++ {
other := &b.Snakes[j]
if areSnakesOnSameSquad(squadSettings.squadMap, snake, other) {
if squadSettings.SharedHealth {
if snake.Health < other.Health {
snake.Health = other.Health
}
}
if squadSettings.SharedLength {
if len(snake.Body) == 0 || len(other.Body) == 0 {
return false, errors.New("found snake of zero length")
}
for len(snake.Body) < len(other.Body) {
growSnake(snake)
}
}
if squadSettings.SharedElimination {
if snake.EliminatedCause == NotEliminated && other.EliminatedCause != NotEliminated {
snake.EliminatedCause = EliminatedBySquad
// We intentionally do not set snake.EliminatedBy because there might be multiple culprits.
snake.EliminatedBy = ""
}
}
}
}
}
return false, nil
}
func (r *SquadRuleset) IsGameOver(b *BoardState) (bool, error) {
return GameOverSquad(b, r.Settings(), nil)
}
func GameOverSquad(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
snakesRemaining := []*Snake{}
for i := 0; i < len(b.Snakes); i++ {
if b.Snakes[i].EliminatedCause == NotEliminated {
snakesRemaining = append(snakesRemaining, &b.Snakes[i])
}
}
for i := 0; i < len(snakesRemaining); i++ {
if !areSnakesOnSameSquad(settings.SquadSettings.squadMap, snakesRemaining[i], snakesRemaining[0]) {
// There are multiple squads remaining
return false, nil
}
}
// no snakes or single squad remaining
return true, nil
}
func (r SquadRuleset) Settings() Settings {
s := r.StandardRuleset.Settings()
s.SquadSettings = SquadSettings{
squadMap: r.SquadMap,
AllowBodyCollisions: r.AllowBodyCollisions,
SharedElimination: r.SharedElimination,
SharedHealth: r.SharedHealth,
SharedLength: r.SharedLength,
}
return s
}

View file

@ -1,582 +0,0 @@
package rules
import (
"math/rand"
"testing"
"github.com/stretchr/testify/require"
)
func TestSquadRulesetInterface(t *testing.T) {
var _ Ruleset = (*SquadRuleset)(nil)
}
func TestSquadName(t *testing.T) {
r := SquadRuleset{}
require.Equal(t, "squad", r.Name())
}
func TestSquadCreateNextBoardStateSanity(t *testing.T) {
boardState := &BoardState{}
r := SquadRuleset{}
_, err := r.CreateNextBoardState(boardState, []SnakeMove{})
require.NoError(t, err)
}
func TestSquadResurrectSquadBodyCollisionsSanity(t *testing.T) {
boardState := &BoardState{}
r := SquadRuleset{}
_, err := ResurrectSnakesSquad(boardState, r.Settings(), nil)
require.NoError(t, err)
}
func TestSquadSharedAttributesSanity(t *testing.T) {
boardState := &BoardState{}
r := SquadRuleset{}
_, err := ShareAttributesSquad(boardState, r.Settings(), nil)
require.NoError(t, err)
}
func TestSquadAllowBodyCollisions(t *testing.T) {
testSnakes := []struct {
SnakeID string
SquadID string
EliminatedCause string
EliminatedBy string
ExpectedCause string
ExpectedBy string
}{
// Red Squad
{"R1", "red", NotEliminated, "", NotEliminated, ""},
{"R2", "red", EliminatedByCollision, "R1", NotEliminated, ""},
// Blue Squad
{"B1", "blue", EliminatedByCollision, "R1", EliminatedByCollision, "R1"},
{"B2", "blue", EliminatedBySelfCollision, "B1", EliminatedBySelfCollision, "B1"},
{"B4", "blue", EliminatedByOutOfBounds, "", EliminatedByOutOfBounds, ""},
{"B3", "blue", NotEliminated, "", NotEliminated, ""},
// More Red Squad
{"R3", "red", NotEliminated, "", NotEliminated, ""},
{"R4", "red", EliminatedByCollision, "R4", EliminatedByCollision, "R4"}, // this is an error case but worth testing
{"R5", "red", EliminatedByCollision, "R4", NotEliminated, ""},
// Green Squad
{"G1", "green", EliminatedByOutOfHealth, "x", EliminatedByOutOfHealth, "x"},
// Yellow Squad
{"Y1", "yellow", EliminatedByCollision, "B4", EliminatedByCollision, "B4"},
}
boardState := &BoardState{}
squadMap := make(map[string]string)
for _, testSnake := range testSnakes {
boardState.Snakes = append(boardState.Snakes, Snake{
ID: testSnake.SnakeID,
EliminatedCause: testSnake.EliminatedCause,
EliminatedBy: testSnake.EliminatedBy,
})
squadMap[testSnake.SnakeID] = testSnake.SquadID
}
require.Equal(t, len(squadMap), len(boardState.Snakes), "squad map is wrong size, error in test setup")
r := SquadRuleset{SquadMap: squadMap, AllowBodyCollisions: true}
_, err := ResurrectSnakesSquad(boardState, r.Settings(), mockSnakeMoves())
require.NoError(t, err)
require.Equal(t, len(boardState.Snakes), len(testSnakes))
for i := 0; i < len(boardState.Snakes); i++ {
require.Equal(
t,
testSnakes[i].ExpectedCause,
boardState.Snakes[i].EliminatedCause,
"snake %s failed shared eliminated cause",
testSnakes[i].SnakeID,
)
require.Equal(
t,
testSnakes[i].ExpectedBy,
boardState.Snakes[i].EliminatedBy,
"snake %s failed shared eliminated by",
testSnakes[i].SnakeID,
)
}
}
func TestSquadAllowBodyCollisionsEliminatedByNotSet(t *testing.T) {
boardState := &BoardState{
Snakes: []Snake{
{ID: "1", EliminatedCause: EliminatedByCollision},
{ID: "2"},
},
}
r := SquadRuleset{
AllowBodyCollisions: true,
SquadMap: map[string]string{
"1": "red",
"2": "red",
},
}
_, err := ResurrectSnakesSquad(boardState, r.Settings(), mockSnakeMoves())
require.Error(t, err)
}
func TestSquadShareSquadHealth(t *testing.T) {
testSnakes := []struct {
SnakeID string
SquadID string
Health int
ExpectedHealth int
}{
// Red Squad
{"R1", "red", 11, 88},
{"R2", "red", 22, 88},
// Blue Squad
{"B1", "blue", 33, 333},
{"B2", "blue", 333, 333},
{"B3", "blue", 3, 333},
// More Red Squad
{"R3", "red", 77, 88},
{"R4", "red", 88, 88},
// Green Squad
{"G1", "green", 100, 100},
// Yellow Squad
{"Y1", "yellow", 1, 1},
}
boardState := &BoardState{}
squadMap := make(map[string]string)
for _, testSnake := range testSnakes {
boardState.Snakes = append(boardState.Snakes, Snake{
ID: testSnake.SnakeID,
Health: testSnake.Health,
})
squadMap[testSnake.SnakeID] = testSnake.SquadID
}
require.Equal(t, len(squadMap), len(boardState.Snakes), "squad map is wrong size, error in test setup")
r := SquadRuleset{SharedHealth: true, SquadMap: squadMap}
_, err := ShareAttributesSquad(boardState, r.Settings(), mockSnakeMoves())
require.NoError(t, err)
require.Equal(t, len(boardState.Snakes), len(testSnakes))
for i := 0; i < len(boardState.Snakes); i++ {
require.Equal(
t,
testSnakes[i].ExpectedHealth,
boardState.Snakes[i].Health,
"snake %s failed shared health",
testSnakes[i].SnakeID,
)
}
}
func TestSquadSharedLength(t *testing.T) {
testSnakes := []struct {
SnakeID string
SquadID string
Body []Point
ExpectedBody []Point
}{
// Red Squad
{"R1", "red", []Point{{1, 1}}, []Point{{1, 1}, {1, 1}, {1, 1}, {1, 1}, {1, 1}}},
{"R2", "red", []Point{{2, 2}, {2, 2}}, []Point{{2, 2}, {2, 2}, {2, 2}, {2, 2}, {2, 2}}},
// Blue Squad
{"B1", "blue", []Point{{1, 1}, {1, 2}}, []Point{{1, 1}, {1, 2}}},
{"B2", "blue", []Point{{2, 1}}, []Point{{2, 1}, {2, 1}}},
{"B3", "blue", []Point{{3, 3}}, []Point{{3, 3}, {3, 3}}},
// More Red Squad
{"R3", "red", []Point{{1, 1}, {2, 2}, {3, 3}, {4, 4}, {5, 5}}, []Point{{1, 1}, {2, 2}, {3, 3}, {4, 4}, {5, 5}}},
{"R4", "red", []Point{{4, 4}}, []Point{{4, 4}, {4, 4}, {4, 4}, {4, 4}, {4, 4}}},
// Green Squad
{"G1", "green", []Point{{1, 1}}, []Point{{1, 1}}},
// Yellow Squad
{"Y1", "yellow", []Point{{1, 3}, {1, 4}, {1, 5}, {1, 6}}, []Point{{1, 3}, {1, 4}, {1, 5}, {1, 6}}},
}
boardState := &BoardState{}
squadMap := make(map[string]string)
for _, testSnake := range testSnakes {
boardState.Snakes = append(boardState.Snakes, Snake{
ID: testSnake.SnakeID,
Body: testSnake.Body,
})
squadMap[testSnake.SnakeID] = testSnake.SquadID
}
require.Equal(t, len(squadMap), len(boardState.Snakes), "squad map is wrong size, error in test setup")
r := SquadRuleset{SharedLength: true, SquadMap: squadMap}
_, err := ShareAttributesSquad(boardState, r.Settings(), mockSnakeMoves())
require.NoError(t, err)
require.Equal(t, len(boardState.Snakes), len(testSnakes))
for i := 0; i < len(boardState.Snakes); i++ {
require.Equal(
t,
testSnakes[i].ExpectedBody,
boardState.Snakes[i].Body,
"snake %s failed shared length",
testSnakes[i].SnakeID,
)
}
}
func TestSquadSharedElimination(t *testing.T) {
testSnakes := []struct {
SnakeID string
SquadID string
EliminatedCause string
EliminatedBy string
ExpectedCause string
ExpectedBy string
}{
// Red Squad
{"R1", "red", NotEliminated, "", EliminatedBySquad, ""},
{"R2", "red", EliminatedByHeadToHeadCollision, "y", EliminatedByHeadToHeadCollision, "y"},
// Blue Squad
{"B1", "blue", EliminatedByOutOfBounds, "z", EliminatedByOutOfBounds, "z"},
{"B2", "blue", NotEliminated, "", EliminatedBySquad, ""},
{"B3", "blue", NotEliminated, "", EliminatedBySquad, ""},
// More Red Squad
{"R3", "red", NotEliminated, "", EliminatedBySquad, ""},
{"R4", "red", EliminatedByCollision, "B1", EliminatedByCollision, "B1"},
// Green Squad
{"G1", "green", EliminatedByOutOfHealth, "x", EliminatedByOutOfHealth, "x"},
// Yellow Squad
{"Y1", "yellow", NotEliminated, "", NotEliminated, ""},
}
boardState := &BoardState{}
squadMap := make(map[string]string)
for _, testSnake := range testSnakes {
boardState.Snakes = append(boardState.Snakes, Snake{
ID: testSnake.SnakeID,
EliminatedCause: testSnake.EliminatedCause,
EliminatedBy: testSnake.EliminatedBy,
})
squadMap[testSnake.SnakeID] = testSnake.SquadID
}
require.Equal(t, len(squadMap), len(boardState.Snakes), "squad map is wrong size, error in test setup")
r := SquadRuleset{SharedElimination: true, SquadMap: squadMap}
_, err := ShareAttributesSquad(boardState, r.Settings(), mockSnakeMoves())
require.NoError(t, err)
require.Equal(t, len(boardState.Snakes), len(testSnakes))
for i := 0; i < len(boardState.Snakes); i++ {
require.Equal(
t,
testSnakes[i].ExpectedCause,
boardState.Snakes[i].EliminatedCause,
"snake %s failed shared eliminated cause",
testSnakes[i].SnakeID,
)
require.Equal(
t,
testSnakes[i].ExpectedBy,
boardState.Snakes[i].EliminatedBy,
"snake %s failed shared eliminated by",
testSnakes[i].SnakeID,
)
}
}
func TestSquadSharedAttributesErrorLengthZero(t *testing.T) {
boardState := &BoardState{
Snakes: []Snake{
{ID: "1"},
{ID: "2"},
},
}
r := SquadRuleset{
SharedLength: true,
SquadMap: map[string]string{
"1": "red",
"2": "red",
},
}
_, err := ShareAttributesSquad(boardState, r.Settings(), mockSnakeMoves())
require.Error(t, err)
}
func TestSquadIsGameOver(t *testing.T) {
tests := []struct {
Snakes []Snake
SquadMap map[string]string
Expected bool
}{
{[]Snake{}, map[string]string{}, true},
{[]Snake{{ID: "R1"}}, map[string]string{"R1": "red"}, true},
{
[]Snake{{ID: "R1"}, {ID: "R2"}, {ID: "R3"}},
map[string]string{"R1": "red", "R2": "red", "R3": "red"},
true,
},
{
[]Snake{{ID: "R1"}, {ID: "B1"}},
map[string]string{"R1": "red", "B1": "blue"},
false,
},
{
[]Snake{{ID: "R1"}, {ID: "B1"}, {ID: "B2"}, {ID: "G1"}},
map[string]string{"R1": "red", "B1": "blue", "B2": "blue", "G1": "green"},
false,
},
{
[]Snake{
{ID: "R1", EliminatedCause: EliminatedByOutOfBounds},
{ID: "B1", EliminatedCause: EliminatedBySelfCollision, EliminatedBy: "B1"},
{ID: "B2", EliminatedCause: EliminatedByCollision, EliminatedBy: "B2"},
{ID: "G1"},
},
map[string]string{"R1": "red", "B1": "blue", "B2": "blue", "G1": "green"},
true,
},
}
for _, test := range tests {
b := &BoardState{
Height: 11,
Width: 11,
Snakes: test.Snakes,
Food: []Point{},
}
r := SquadRuleset{SquadMap: test.SquadMap}
actual, err := r.IsGameOver(b)
require.NoError(t, err)
require.Equal(t, test.Expected, actual)
}
}
func TestRegressionIssue16(t *testing.T) {
// This is a specific test case to detect this issue:
// https://github.com/BattlesnakeOfficial/rules/issues/16
boardState := &BoardState{
Width: 11,
Height: 11,
Snakes: []Snake{
{ID: "teamBoi", Health: 10, Body: []Point{{1, 4}, {1, 3}, {0, 3}, {0, 2}, {1, 2}, {2, 2}}},
{ID: "Node-Red-Bellied-Black-Snake", Health: 10, Body: []Point{{1, 8}, {2, 8}, {2, 9}, {3, 9}, {4, 9}, {4, 10}}},
{ID: "Crash Override", Health: 10, Body: []Point{{2, 7}, {2, 6}, {3, 6}, {4, 6}, {4, 5}, {5, 5}, {6, 5}}},
{ID: "Zero Cool", Health: 10, Body: []Point{{6, 5}, {5, 5}, {5, 4}, {5, 3}, {4, 3}, {3, 3}, {3, 4}}},
},
}
squadMap := map[string]string{
"teamBoi": "BirdSnakers",
"Node-Red-Bellied-Black-Snake": "BirdSnakers",
"Crash Override": "Hackers",
"Zero Cool": "Hackers",
}
snakeMoves := []SnakeMove{
{ID: "teamBoi", Move: "up"},
{ID: "Node-Red-Bellied-Black-Snake", Move: "left"},
{ID: "Crash Override", Move: "left"},
{ID: "Zero Cool", Move: "left"},
}
require.Equal(t, len(squadMap), len(boardState.Snakes), "squad map is wrong size, error in test setup")
r := SquadRuleset{
AllowBodyCollisions: true,
SquadMap: squadMap,
}
nextBoardState, err := r.CreateNextBoardState(boardState, snakeMoves)
require.NoError(t, err)
require.Equal(t, len(boardState.Snakes), len(nextBoardState.Snakes))
expectedSnakes := []Snake{
{ID: "teamBoi", Body: []Point{{1, 5}, {1, 4}, {1, 3}, {0, 3}, {0, 2}, {1, 2}}},
{ID: "Node-Red-Bellied-Black-Snake", Body: []Point{{0, 8}, {1, 8}, {2, 8}, {2, 9}, {3, 9}, {4, 9}}},
{ID: "Crash Override", Body: []Point{{1, 7}, {2, 7}, {2, 6}, {3, 6}, {4, 6}, {4, 5}, {5, 5}}},
{ID: "Zero Cool", Body: []Point{{5, 5}, {6, 5}, {5, 5}, {5, 4}, {5, 3}, {4, 3}, {3, 3}}, EliminatedCause: EliminatedBySelfCollision, EliminatedBy: "Zero Cool"},
}
for i, snake := range nextBoardState.Snakes {
require.Equal(t, expectedSnakes[i].ID, snake.ID, snake.ID)
require.Equal(t, expectedSnakes[i].Body, snake.Body, snake.ID)
require.Equal(t, expectedSnakes[i].EliminatedCause, snake.EliminatedCause, snake.ID)
require.Equal(t, expectedSnakes[i].EliminatedBy, snake.EliminatedBy, snake.ID)
}
}
// Checks that snakes on the same squad don't get eliminated
// when the allow squad collisions setting is enabled
// Both squads have snakes that move into each other.
var squadCaseMoveSquadCollisions = gameTestCase{
"Squad Case Move Squad Collisions",
&BoardState{
Width: 10,
Height: 10,
Snakes: []Snake{
{
ID: "snake1squad1",
Body: []Point{{1, 1}, {2, 1}},
Health: 100,
},
{
ID: "snake2squad1",
Body: []Point{{1, 2}, {2, 2}},
Health: 100,
},
{
ID: "snake3squad2",
Body: []Point{{4, 4}, {4, 5}},
Health: 100,
},
{
ID: "snake4squad2",
Body: []Point{{5, 4}, {5, 5}},
Health: 100,
},
},
Food: []Point{},
Hazards: []Point{},
},
[]SnakeMove{
{ID: "snake1squad1", Move: MoveUp},
{ID: "snake2squad1", Move: MoveDown},
{ID: "snake3squad2", Move: MoveRight},
{ID: "snake4squad2", Move: MoveLeft},
},
nil,
&BoardState{Width: 10,
Height: 10,
Snakes: []Snake{
{
ID: "snake1squad1",
Body: []Point{{1, 2}, {1, 1}},
Health: 99,
},
{
ID: "snake2squad1",
Body: []Point{{1, 1}, {1, 2}},
Health: 99,
},
{
ID: "snake3squad2",
Body: []Point{{5, 4}, {4, 4}},
Health: 99,
},
{
ID: "snake4squad2",
Body: []Point{{4, 4}, {5, 4}},
Health: 99,
},
},
Food: []Point{},
Hazards: []Point{}},
}
// Checks snakes on the same squad share health (assuming the setting is enabled)
var squadCaseEatFoodAndShareHealth = gameTestCase{
"Squad Case Move Squad Collisions",
&BoardState{
Width: 10,
Height: 10,
Snakes: []Snake{
{
ID: "snake1squad1",
Body: []Point{{1, 1}, {2, 1}},
Health: 80,
},
{
ID: "snake2squad1",
Body: []Point{{7, 7}, {7, 8}},
Health: 50,
},
{
ID: "snake3squad2",
Body: []Point{{4, 4}, {4, 5}},
Health: 60,
},
{
ID: "snake4squad2",
Body: []Point{{5, 4}, {5, 5}},
Health: 71,
},
},
Food: []Point{{1, 2}},
Hazards: []Point{},
},
[]SnakeMove{
{ID: "snake1squad1", Move: MoveUp},
{ID: "snake2squad1", Move: MoveDown},
{ID: "snake3squad2", Move: MoveRight},
{ID: "snake4squad2", Move: MoveLeft},
},
nil,
&BoardState{
Width: 10,
Height: 10,
Snakes: []Snake{
{
ID: "snake1squad1",
Body: []Point{{1, 2}, {1, 1}, {1, 1}},
Health: 100,
},
{
ID: "snake2squad1",
Body: []Point{{7, 6}, {7, 7}},
Health: 100,
},
{
ID: "snake3squad2",
Body: []Point{{5, 4}, {4, 4}},
Health: 70,
},
{
ID: "snake4squad2",
Body: []Point{{4, 4}, {5, 4}},
Health: 70,
},
},
Food: []Point{},
Hazards: []Point{}},
}
func TestSquadCreateNextBoardState(t *testing.T) {
standardCases := []gameTestCase{
// inherits these test cases from standard
standardCaseErrNoMoveFound,
standardCaseErrZeroLengthSnake,
standardCaseMoveEatAndGrow,
}
r := SquadRuleset{
SquadMap: map[string]string{
"snake1squad1": "squad1",
"snake2squad1": "squad1",
"snake3squad2": "squad2",
"snake4squad2": "squad2",
},
}
rand.Seed(0)
rb := NewRulesetBuilder().WithParams(map[string]string{
ParamGameType: GameTypeSquad,
})
rb.WithSeed(0)
for s, ss := range r.SquadMap {
rb = rb.AddSnakeToSquad(s, ss)
}
for _, gc := range standardCases {
gc.requireValidNextState(t, &r)
// also test a RulesBuilder constructed instance
gc.requireValidNextState(t, rb.Ruleset())
// also test a pipeline with the same settings
gc.requireValidNextState(t, rb.PipelineRuleset(GameTypeSquad, NewPipeline(squadRulesetStages...)))
}
extendedCases := []gameTestCase{
squadCaseMoveSquadCollisions,
squadCaseEatFoodAndShareHealth,
}
r.SharedHealth = true
r.AllowBodyCollisions = true
rb = rb.WithParams(map[string]string{
ParamSharedHealth: "true",
ParamAllowBodyCollisions: "true",
})
for _, gc := range extendedCases {
gc.requireValidNextState(t, &r)
// also test a RulesBuilder constructed instance
gc.requireValidNextState(t, rb.Ruleset())
// also test a pipeline with the same settings
gc.requireValidNextState(t, rb.PipelineRuleset(GameTypeSquad, NewPipeline(squadRulesetStages...)))
}
}

View file

@ -18,7 +18,6 @@ var standardRulesetStages = []string{
StageStarvationStandard, StageStarvationStandard,
StageHazardDamageStandard, StageHazardDamageStandard,
StageFeedSnakesStandard, StageFeedSnakesStandard,
StageSpawnFoodStandard,
StageEliminationStandard, StageEliminationStandard,
StageGameOverStandard, StageGameOverStandard,
} }
@ -387,6 +386,7 @@ func growSnake(snake *Snake) {
} }
} }
// Deprecated: handled by maps.Standard
func SpawnFoodStandard(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) { func SpawnFoodStandard(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
if IsInitialization(b, settings, moves) { if IsInitialization(b, settings, moves) {
return false, nil return false, nil

View file

@ -5,7 +5,6 @@ var wrappedRulesetStages = []string{
StageStarvationStandard, StageStarvationStandard,
StageHazardDamageStandard, StageHazardDamageStandard,
StageFeedSnakesStandard, StageFeedSnakesStandard,
StageSpawnFoodStandard,
StageEliminationStandard, StageEliminationStandard,
StageGameOverStandard, StageGameOverStandard,
} }