DEV 1247: Add a new map generator interface (#71)

* reorganize code

* first draft of map generator interfaces

* add explicit random interface to board helpers

* implement standard map

* rename Generator to GameMap

* allow initializing snakes separately from placing them

* add random number generator to Settings

* updates to GameMap interface

* add helpers for creating and updating BoardState with maps
This commit is contained in:
Rob O'Dwyer 2022-05-11 08:26:28 -07:00 committed by GitHub
parent 1c3f434841
commit dab9178a55
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 916 additions and 160 deletions

View file

@ -1,9 +1,5 @@
package rules
import (
"math/rand"
)
type BoardState struct {
Turn int32
Height int32
@ -13,6 +9,20 @@ type BoardState struct {
Hazards []Point
}
type Point struct {
X int32
Y int32
}
type Snake struct {
ID string
Body []Point
Health int32
EliminatedCause string
EliminatedOnTurn int32
EliminatedBy string
}
// NewBoardState returns an empty but fully initialized BoardState
func NewBoardState(width, height int32) *BoardState {
return &BoardState{
@ -49,15 +59,15 @@ func (prevState *BoardState) Clone() *BoardState {
// "default" board state with snakes and food.
// In a real game, the engine may generate the board without calling this
// function, or customize the results based on game-specific settings.
func CreateDefaultBoardState(width int32, height int32, snakeIDs []string) (*BoardState, error) {
func CreateDefaultBoardState(rand Rand, width int32, height int32, snakeIDs []string) (*BoardState, error) {
initialBoardState := NewBoardState(width, height)
err := PlaceSnakesAutomatically(initialBoardState, snakeIDs)
err := PlaceSnakesAutomatically(rand, initialBoardState, snakeIDs)
if err != nil {
return nil, err
}
err = PlaceFoodAutomatically(initialBoardState)
err = PlaceFoodAutomatically(rand, initialBoardState)
if err != nil {
return nil, err
}
@ -66,14 +76,14 @@ func CreateDefaultBoardState(width int32, height int32, snakeIDs []string) (*Boa
}
// PlaceSnakesAutomatically initializes the array of snakes based on the provided snake IDs and the size of the board.
func PlaceSnakesAutomatically(b *BoardState, snakeIDs []string) error {
func PlaceSnakesAutomatically(rand Rand, b *BoardState, snakeIDs []string) error {
if isKnownBoardSize(b) {
return PlaceSnakesFixed(b, snakeIDs)
return PlaceSnakesFixed(rand, b, snakeIDs)
}
return PlaceSnakesRandomly(b, snakeIDs)
return PlaceSnakesRandomly(rand, b, snakeIDs)
}
func PlaceSnakesFixed(b *BoardState, snakeIDs []string) error {
func PlaceSnakesFixed(rand Rand, b *BoardState, snakeIDs []string) error {
b.Snakes = make([]Snake, len(snakeIDs))
for i := 0; i < len(snakeIDs); i++ {
@ -116,7 +126,7 @@ func PlaceSnakesFixed(b *BoardState, snakeIDs []string) error {
return nil
}
func PlaceSnakesRandomly(b *BoardState, snakeIDs []string) error {
func PlaceSnakesRandomly(rand Rand, b *BoardState, snakeIDs []string) error {
b.Snakes = make([]Snake, len(snakeIDs))
for i := 0; i < len(snakeIDs); i++ {
@ -127,7 +137,7 @@ func PlaceSnakesRandomly(b *BoardState, snakeIDs []string) error {
}
for i := 0; i < len(b.Snakes); i++ {
unoccupiedPoints := getEvenUnoccupiedPoints(b)
unoccupiedPoints := GetEvenUnoccupiedPoints(b)
if len(unoccupiedPoints) <= 0 {
return ErrorNoRoomForSnake
}
@ -139,8 +149,30 @@ func PlaceSnakesRandomly(b *BoardState, snakeIDs []string) error {
return nil
}
// Adds all snakes without body coordinates to the board.
// This allows GameMaps to access the list of snakes and perform initial placement.
func InitializeSnakes(b *BoardState, snakeIDs []string) {
b.Snakes = make([]Snake, len(snakeIDs))
for i := 0; i < len(snakeIDs); i++ {
b.Snakes[i] = Snake{
ID: snakeIDs[i],
Health: SnakeMaxHealth,
Body: []Point{},
}
}
}
// PlaceSnake adds a snake to the board with the given ID and body coordinates.
func PlaceSnake(b *BoardState, snakeID string, body []Point) error {
// Update an existing snake that already has a body
for index, snake := range b.Snakes {
if snake.ID == snakeID {
b.Snakes[index].Body = body
return nil
}
}
// Add a new snake
b.Snakes = append(b.Snakes, Snake{
ID: snakeID,
Health: SnakeMaxHealth,
@ -150,14 +182,14 @@ func PlaceSnake(b *BoardState, snakeID string, body []Point) error {
}
// PlaceFoodAutomatically initializes the array of food based on the size of the board and the number of snakes.
func PlaceFoodAutomatically(b *BoardState) error {
func PlaceFoodAutomatically(rand Rand, b *BoardState) error {
if isKnownBoardSize(b) {
return PlaceFoodFixed(b)
return PlaceFoodFixed(rand, b)
}
return PlaceFoodRandomly(b, int32(len(b.Snakes)))
return PlaceFoodRandomly(rand, b, int32(len(b.Snakes)))
}
func PlaceFoodFixed(b *BoardState) error {
func PlaceFoodFixed(rand Rand, b *BoardState) error {
centerCoord := Point{(b.Width - 1) / 2, (b.Height - 1) / 2}
// Place 1 food within exactly 2 moves of each snake, but never towards the center or in a corner
@ -220,7 +252,7 @@ func PlaceFoodFixed(b *BoardState) error {
// Finally, always place 1 food in center of board for dramatic purposes
isCenterOccupied := true
unoccupiedPoints := getUnoccupiedPoints(b, true)
unoccupiedPoints := GetUnoccupiedPoints(b, true)
for _, point := range unoccupiedPoints {
if point == centerCoord {
isCenterOccupied = false
@ -236,9 +268,9 @@ func PlaceFoodFixed(b *BoardState) error {
}
// PlaceFoodRandomly adds up to n new food to the board in random unoccupied squares
func PlaceFoodRandomly(b *BoardState, n int32) error {
func PlaceFoodRandomly(rand Rand, b *BoardState, n int32) error {
for i := int32(0); i < n; i++ {
unoccupiedPoints := getUnoccupiedPoints(b, false)
unoccupiedPoints := GetUnoccupiedPoints(b, false)
if len(unoccupiedPoints) > 0 {
newFood := unoccupiedPoints[rand.Intn(len(unoccupiedPoints))]
b.Food = append(b.Food, newFood)
@ -254,9 +286,9 @@ func absInt32(n int32) int32 {
return n
}
func getEvenUnoccupiedPoints(b *BoardState) []Point {
func GetEvenUnoccupiedPoints(b *BoardState) []Point {
// Start by getting unoccupied points
unoccupiedPoints := getUnoccupiedPoints(b, true)
unoccupiedPoints := GetUnoccupiedPoints(b, true)
// Create a new array to hold points that are even
evenUnoccupiedPoints := []Point{}
@ -269,7 +301,7 @@ func getEvenUnoccupiedPoints(b *BoardState) []Point {
return evenUnoccupiedPoints
}
func getUnoccupiedPoints(b *BoardState, includePossibleMoves bool) []Point {
func GetUnoccupiedPoints(b *BoardState, includePossibleMoves bool) []Point {
pointIsOccupied := map[int32]map[int32]bool{}
for _, p := range b.Food {
if _, xExists := pointIsOccupied[p.X]; !xExists {

View file

@ -37,7 +37,7 @@ func TestCreateDefaultBoardState(t *testing.T) {
}
for testNum, test := range tests {
state, err := CreateDefaultBoardState(test.Width, test.Height, test.IDs)
state, err := CreateDefaultBoardState(MaxRand, test.Width, test.Height, test.IDs)
require.Equal(t, test.Err, err)
if err != nil {
require.Nil(t, state)
@ -196,8 +196,8 @@ func TestPlaceSnakesDefault(t *testing.T) {
for _, test := range tests {
t.Run(fmt.Sprint(test.BoardState.Width, test.BoardState.Height, len(test.SnakeIDs)), func(t *testing.T) {
require.Equal(t, test.BoardState.Width*test.BoardState.Height, int32(len(getUnoccupiedPoints(test.BoardState, true))))
err := PlaceSnakesAutomatically(test.BoardState, test.SnakeIDs)
require.Equal(t, test.BoardState.Width*test.BoardState.Height, int32(len(GetUnoccupiedPoints(test.BoardState, true))))
err := PlaceSnakesAutomatically(MaxRand, test.BoardState, test.SnakeIDs)
require.Equal(t, test.Err, err, "Snakes: %d", len(test.BoardState.Snakes))
if err == nil {
for i := 0; i < len(test.BoardState.Snakes); i++ {
@ -338,7 +338,7 @@ func TestPlaceFood(t *testing.T) {
for _, test := range tests {
require.Len(t, test.BoardState.Food, 0)
err := PlaceFoodAutomatically(test.BoardState)
err := PlaceFoodAutomatically(MaxRand, test.BoardState)
require.NoError(t, err)
require.Equal(t, test.ExpectedFood, len(test.BoardState.Food))
for _, point := range test.BoardState.Food {
@ -396,7 +396,7 @@ func TestPlaceFoodFixed(t *testing.T) {
for _, test := range tests {
require.Len(t, test.BoardState.Food, 0)
err := PlaceFoodFixed(test.BoardState)
err := PlaceFoodFixed(MaxRand, test.BoardState)
require.NoError(t, err)
require.Equal(t, len(test.BoardState.Snakes)+1, len(test.BoardState.Food))
@ -444,7 +444,7 @@ func TestPlaceFoodFixedNoRoom(t *testing.T) {
},
Food: []Point{},
}
err := PlaceFoodFixed(boardState)
err := PlaceFoodFixed(MaxRand, boardState)
require.Error(t, err)
}
@ -463,18 +463,18 @@ func TestPlaceFoodFixedNoRoom_Corners(t *testing.T) {
// There are only two possible food spawn locations for each snake,
// so repeat calls to place food should fail after 2 successes
err := PlaceFoodFixed(boardState)
err := PlaceFoodFixed(MaxRand, boardState)
require.NoError(t, err)
boardState.Food = boardState.Food[:len(boardState.Food)-1] // Center food
require.Equal(t, 4, len(boardState.Food))
err = PlaceFoodFixed(boardState)
err = PlaceFoodFixed(MaxRand, boardState)
require.NoError(t, err)
boardState.Food = boardState.Food[:len(boardState.Food)-1] // Center food
require.Equal(t, 8, len(boardState.Food))
// And now there should be no more room.
err = PlaceFoodFixed(boardState)
err = PlaceFoodFixed(MaxRand, boardState)
require.Error(t, err)
expectedFood := []Point{
@ -503,18 +503,18 @@ func TestPlaceFoodFixedNoRoom_Cardinal(t *testing.T) {
// There are only two possible spawn locations for each snake,
// so repeat calls to place food should fail after 2 successes
err := PlaceFoodFixed(boardState)
err := PlaceFoodFixed(MaxRand, boardState)
require.NoError(t, err)
boardState.Food = boardState.Food[:len(boardState.Food)-1] // Center food
require.Equal(t, 4, len(boardState.Food))
err = PlaceFoodFixed(boardState)
err = PlaceFoodFixed(MaxRand, boardState)
require.NoError(t, err)
boardState.Food = boardState.Food[:len(boardState.Food)-1] // Center food
require.Equal(t, 8, len(boardState.Food))
// And now there should be no more room.
err = PlaceFoodFixed(boardState)
err = PlaceFoodFixed(MaxRand, boardState)
require.Error(t, err)
expectedFood := []Point{
@ -653,7 +653,7 @@ func TestGetUnoccupiedPoints(t *testing.T) {
}
for _, test := range tests {
unoccupiedPoints := getUnoccupiedPoints(test.Board, true)
unoccupiedPoints := GetUnoccupiedPoints(test.Board, true)
require.Equal(t, len(test.Expected), len(unoccupiedPoints))
for i, e := range test.Expected {
require.Equal(t, e, unoccupiedPoints[i])
@ -739,7 +739,7 @@ func TestGetEvenUnoccupiedPoints(t *testing.T) {
}
for _, test := range tests {
evenUnoccupiedPoints := getEvenUnoccupiedPoints(test.Board)
evenUnoccupiedPoints := GetEvenUnoccupiedPoints(test.Board)
require.Equal(t, len(test.Expected), len(evenUnoccupiedPoints))
for i, e := range test.Expected {
require.Equal(t, e, evenUnoccupiedPoints[i])
@ -756,7 +756,7 @@ func TestPlaceFoodRandomly(t *testing.T) {
},
}
// Food should never spawn, no room
err := PlaceFoodRandomly(b, 99)
err := PlaceFoodRandomly(MaxRand, b, 99)
require.NoError(t, err)
require.Equal(t, len(b.Food), 0)
}

View file

@ -231,7 +231,7 @@ func initializeBoardFromArgs(ruleset rules.Ruleset, snakeStates map[string]Snake
for _, snakeState := range snakeStates {
snakeIds = append(snakeIds, snakeState.ID)
}
state, err := rules.CreateDefaultBoardState(Width, Height, snakeIds)
state, err := rules.CreateDefaultBoardState(rules.GlobalRand, Width, Height, snakeIds)
if err != nil {
log.Panic("[PANIC]: Error Initializing Board State")
}

60
constants.go Normal file
View file

@ -0,0 +1,60 @@
package rules
type RulesetError string
func (err RulesetError) Error() string { return string(err) }
const (
MoveUp = "up"
MoveDown = "down"
MoveRight = "right"
MoveLeft = "left"
BoardSizeSmall = 7
BoardSizeMedium = 11
BoardSizeLarge = 19
SnakeMaxHealth = 100
SnakeStartSize = 3
// Snake state constants
NotEliminated = ""
EliminatedByCollision = "snake-collision"
EliminatedBySelfCollision = "snake-self-collision"
EliminatedByOutOfHealth = "out-of-health"
EliminatedByHeadToHeadCollision = "head-collision"
EliminatedByOutOfBounds = "wall-collision"
EliminatedBySquad = "squad-eliminated"
// Error constants
ErrorTooManySnakes = RulesetError("too many snakes for fixed start positions")
ErrorNoRoomForSnake = RulesetError("not enough space to place snake")
ErrorNoRoomForFood = RulesetError("not enough space to place food")
ErrorNoMoveFound = RulesetError("move not provided for snake")
ErrorZeroLengthSnake = RulesetError("snake is length zero")
ErrorEmptyRegistry = RulesetError("empty registry")
ErrorNoStages = RulesetError("no stages")
ErrorStageNotFound = RulesetError("stage not found")
ErrorMapNotFound = RulesetError("map not found")
// Ruleset / game type names
GameTypeConstrictor = "constrictor"
GameTypeRoyale = "royale"
GameTypeSolo = "solo"
GameTypeSquad = "squad"
GameTypeStandard = "standard"
GameTypeWrapped = "wrapped"
// Game creation parameter names
ParamGameType = "name"
ParamFoodSpawnChance = "foodSpawnChance"
ParamMinimumFood = "minimumFood"
ParamHazardDamagePerTurn = "damagePerTurn"
ParamHazardMap = "hazardMap"
ParamHazardMapAuthor = "hazardMapAuthor"
ParamShrinkEveryNTurns = "shrinkEveryNTurns"
ParamAllowBodyCollisions = "allowBodyCollisions"
ParamSharedElimination = "sharedElimination"
ParamSharedHealth = "sharedHealth"
ParamSharedLength = "sharedLength"
)

View file

@ -26,7 +26,7 @@ func TestConstrictorModifyInitialBoardState(t *testing.T) {
}
r := ConstrictorRuleset{}
for testNum, test := range tests {
state, err := CreateDefaultBoardState(test.Width, test.Height, test.IDs)
state, err := CreateDefaultBoardState(MaxRand, test.Width, test.Height, test.IDs)
require.NoError(t, err)
require.NotNil(t, state)
state, err = r.ModifyInitialBoardState(state)

115
maps/game_map.go Normal file
View file

@ -0,0 +1,115 @@
package maps
import "github.com/BattlesnakeOfficial/rules"
type GameMap interface {
// Return a unique identifier for this map.
ID() string
// Return non-functional metadata about this map.
Meta() Metadata
// Called to generate a new board. The map is responsible for placing all snakes, food, and hazards.
SetupBoard(initialBoardState *rules.BoardState, settings rules.Settings, editor Editor) error
// Called every turn to optionally update the board.
UpdateBoard(previousBoardState *rules.BoardState, settings rules.Settings, editor Editor) error
}
type Metadata struct {
Name string
Author string
Description string
}
// Editor is used by GameMap implementations to modify the board state.
type Editor interface {
// Returns a random number generator. This MUST be used for any non-deterministic behavior in a GameMap.
Random() rules.Rand
// Clears all food from the board.
ClearFood()
// Clears all hazards from the board.
ClearHazards()
// Adds a food to the board. Does not check for duplicates.
AddFood(rules.Point)
// Adds a hazard to the board. Does not check for duplicates.
AddHazard(rules.Point)
// Removes all food from a specific tile on the board.
RemoveFood(rules.Point)
// Removes all hazards from a specific tile on the board.
RemoveHazard(rules.Point)
// Updates the body and health of a snake.
PlaceSnake(id string, body []rules.Point, health int32)
}
// An Editor backed by a BoardState.
type BoardStateEditor struct {
*rules.BoardState
rand rules.Rand
}
func NewBoardStateEditor(boardState *rules.BoardState, rand rules.Rand) *BoardStateEditor {
return &BoardStateEditor{
BoardState: boardState,
rand: rand,
}
}
func (editor *BoardStateEditor) Random() rules.Rand { return editor.rand }
func (editor *BoardStateEditor) ClearFood() {
editor.Food = []rules.Point{}
}
func (editor *BoardStateEditor) ClearHazards() {
editor.Hazards = []rules.Point{}
}
func (editor *BoardStateEditor) AddFood(p rules.Point) {
editor.Food = append(editor.Food, rules.Point{X: p.X, Y: p.Y})
}
func (editor *BoardStateEditor) AddHazard(p rules.Point) {
editor.Hazards = append(editor.Hazards, rules.Point{X: p.X, Y: p.Y})
}
func (editor *BoardStateEditor) RemoveFood(p rules.Point) {
for index, food := range editor.Food {
if food.X == p.X && food.Y == p.Y {
editor.Food[index] = editor.Food[len(editor.Food)-1]
editor.Food = editor.Food[:len(editor.Food)-1]
}
}
}
func (editor *BoardStateEditor) RemoveHazard(p rules.Point) {
for index, food := range editor.Hazards {
if food.X == p.X && food.Y == p.Y {
editor.Hazards[index] = editor.Hazards[len(editor.Hazards)-1]
editor.Hazards = editor.Hazards[:len(editor.Hazards)-1]
}
}
}
func (editor *BoardStateEditor) PlaceSnake(id string, body []rules.Point, health int32) {
for index, snake := range editor.Snakes {
if snake.ID == id {
editor.Snakes[index].Body = body
editor.Snakes[index].Health = health
return
}
}
editor.Snakes = append(editor.Snakes, rules.Snake{
ID: id,
Health: health,
Body: body,
})
}

64
maps/game_map_test.go Normal file
View file

@ -0,0 +1,64 @@
package maps
import (
"testing"
"github.com/BattlesnakeOfficial/rules"
"github.com/stretchr/testify/require"
)
func TestBoardStateEditorInterface(t *testing.T) {
var _ Editor = (*BoardStateEditor)(nil)
}
func TestBoardStateEditor(t *testing.T) {
boardState := rules.NewBoardState(11, 11)
boardState.Snakes = append(boardState.Snakes, rules.Snake{
ID: "existing_snake",
Health: 100,
})
editor := BoardStateEditor{BoardState: boardState}
editor.AddFood(rules.Point{X: 1, Y: 3})
editor.AddFood(rules.Point{X: 3, Y: 6})
editor.AddFood(rules.Point{X: 3, Y: 7})
editor.RemoveFood(rules.Point{X: 3, Y: 6})
editor.AddHazard(rules.Point{X: 1, Y: 3})
editor.AddHazard(rules.Point{X: 3, Y: 6})
editor.AddHazard(rules.Point{X: 3, Y: 7})
editor.RemoveHazard(rules.Point{X: 3, Y: 6})
editor.PlaceSnake("existing_snake", []rules.Point{{X: 5, Y: 2}, {X: 5, Y: 1}, {X: 5, Y: 0}}, 99)
editor.PlaceSnake("new_snake", []rules.Point{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 1, Y: 1}}, 98)
require.Equal(t, &rules.BoardState{
Width: 11,
Height: 11,
Food: []rules.Point{
{X: 1, Y: 3},
{X: 3, Y: 7},
},
Hazards: []rules.Point{
{X: 1, Y: 3},
{X: 3, Y: 7},
},
Snakes: []rules.Snake{
{
ID: "existing_snake",
Health: 99,
Body: []rules.Point{{X: 5, Y: 2}, {X: 5, Y: 1}, {X: 5, Y: 0}},
},
{
ID: "new_snake",
Health: 98,
Body: []rules.Point{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 1, Y: 1}},
},
},
}, boardState)
editor.ClearFood()
require.Equal(t, []rules.Point{}, boardState.Food)
editor.ClearHazards()
require.Equal(t, []rules.Point{}, boardState.Hazards)
}

42
maps/helpers.go Normal file
View file

@ -0,0 +1,42 @@
package maps
import "github.com/BattlesnakeOfficial/rules"
// SetupBoard is a shortcut for looking up a map by ID and initializing a new board state with it.
func SetupBoard(mapID string, settings rules.Settings, width, height int, snakeIDs []string) (*rules.BoardState, error) {
boardState := rules.NewBoardState(int32(width), int32(height))
rules.InitializeSnakes(boardState, snakeIDs)
gameMap, err := GetMap(mapID)
if err != nil {
return nil, err
}
editor := NewBoardStateEditor(boardState, settings.Rand())
err = gameMap.SetupBoard(boardState, settings, editor)
if err != nil {
return nil, err
}
return boardState, nil
}
// UpdateBoard is a shortcut for looking up a map by ID and updating an existing board state with it.
func UpdateBoard(mapID string, previousBoardState *rules.BoardState, settings rules.Settings) (*rules.BoardState, error) {
gameMap, err := GetMap(mapID)
if err != nil {
return nil, err
}
nextBoardState := previousBoardState.Clone()
editor := NewBoardStateEditor(nextBoardState, settings.Rand())
err = gameMap.SetupBoard(previousBoardState, settings, editor)
if err != nil {
return nil, err
}
return nextBoardState, nil
}

35
maps/registry.go Normal file
View file

@ -0,0 +1,35 @@
package maps
import (
"fmt"
"github.com/BattlesnakeOfficial/rules"
)
// MapRegistry is a mapping of map names to game maps.
type MapRegistry map[string]GameMap
var globalRegistry = MapRegistry{}
// RegisterMap adds a stage to the registry.
// If a map has already been registered this will panic.
func (registry MapRegistry) RegisterMap(id string, m GameMap) {
if _, ok := registry[id]; ok {
panic(fmt.Sprintf("map '%s' has already been registered", id))
}
registry[id] = m
}
// GetMap returns the map associated with the given ID.
func (registry MapRegistry) GetMap(id string) (GameMap, error) {
if m, ok := registry[id]; ok {
return m, nil
}
return nil, rules.ErrorMapNotFound
}
// GetMap returns the map associated with the given ID from the global registry.
func GetMap(id string) (GameMap, error) {
return globalRegistry.GetMap(id)
}

80
maps/standard.go Normal file
View file

@ -0,0 +1,80 @@
package maps
import (
"github.com/BattlesnakeOfficial/rules"
)
type StandardMap struct{}
func init() {
globalRegistry.RegisterMap("standard", StandardMap{})
}
func (m StandardMap) ID() string {
return "standard"
}
func (m StandardMap) Meta() Metadata {
return Metadata{
Name: "Standard",
Description: "Standard snake placement and food spawning",
Author: "Battlesnake",
}
}
func (m StandardMap) SetupBoard(initialBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
snakeIDs := make([]string, 0, len(initialBoardState.Snakes))
for _, snake := range initialBoardState.Snakes {
snakeIDs = append(snakeIDs, snake.ID)
}
tempBoardState, err := rules.CreateDefaultBoardState(editor.Random(), initialBoardState.Width, initialBoardState.Height, snakeIDs)
if err != nil {
return err
}
// Copy food from temp board state
for _, food := range tempBoardState.Food {
editor.AddFood(food)
}
// Copy snakes from temp board state
for _, snake := range tempBoardState.Snakes {
editor.PlaceSnake(snake.ID, snake.Body, snake.Health)
}
return nil
}
func (m StandardMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
minFood := int(settings.MinimumFood)
foodSpawnChance := int(settings.FoodSpawnChance)
numCurrentFood := len(lastBoardState.Food)
if numCurrentFood < minFood {
placeFoodRandomly(lastBoardState, editor, minFood-numCurrentFood)
return nil
}
if foodSpawnChance > 0 && (100-editor.Random().Intn(100)) < foodSpawnChance {
placeFoodRandomly(lastBoardState, editor, 1)
return nil
}
return nil
}
func placeFoodRandomly(b *rules.BoardState, editor Editor, n int) {
unoccupiedPoints := rules.GetUnoccupiedPoints(b, false)
if len(unoccupiedPoints) < n {
n = len(unoccupiedPoints)
}
editor.Random().Shuffle(len(unoccupiedPoints), func(i int, j int) {
unoccupiedPoints[i], unoccupiedPoints[j] = unoccupiedPoints[j], unoccupiedPoints[i]
})
for i := 0; i < n; i++ {
editor.AddFood(unoccupiedPoints[i])
}
}

322
maps/standard_test.go Normal file
View file

@ -0,0 +1,322 @@
package maps
import (
"fmt"
"testing"
"github.com/BattlesnakeOfficial/rules"
"github.com/stretchr/testify/require"
)
func TestStandardMapInterface(t *testing.T) {
var _ GameMap = StandardMap{}
}
func TestStandardMapSetupBoard(t *testing.T) {
m := StandardMap{}
settings := rules.Settings{}
tests := []struct {
name string
initialBoardState *rules.BoardState
rand rules.Rand
expected *rules.BoardState
err error
}{
{
"empty 7x7",
rules.NewBoardState(7, 7),
rules.MinRand,
&rules.BoardState{
Width: 7,
Height: 7,
Snakes: []rules.Snake{},
Food: []rules.Point{{X: 3, Y: 3}},
Hazards: []rules.Point{},
},
nil,
},
{
"not enough room for snakes 7x7",
&rules.BoardState{
Width: 7,
Height: 7,
Snakes: generateSnakes(9),
Food: []rules.Point{},
Hazards: []rules.Point{},
},
rules.MinRand,
nil,
rules.ErrorTooManySnakes,
},
{
"not enough room for snakes 5x5",
&rules.BoardState{
Width: 5,
Height: 5,
Snakes: generateSnakes(14),
Food: []rules.Point{},
Hazards: []rules.Point{},
},
rules.MinRand,
nil,
rules.ErrorNoRoomForSnake,
},
{
"full 11x11 min",
&rules.BoardState{
Width: 11,
Height: 11,
Snakes: generateSnakes(8),
Food: []rules.Point{},
Hazards: []rules.Point{},
},
rules.MinRand,
&rules.BoardState{
Width: 11,
Height: 11,
Snakes: []rules.Snake{
{ID: "1", Body: []rules.Point{{X: 1, Y: 1}, {X: 1, Y: 1}, {X: 1, Y: 1}}, Health: 100},
{ID: "2", Body: []rules.Point{{X: 1, Y: 5}, {X: 1, Y: 5}, {X: 1, Y: 5}}, Health: 100},
{ID: "3", Body: []rules.Point{{X: 1, Y: 9}, {X: 1, Y: 9}, {X: 1, Y: 9}}, Health: 100},
{ID: "4", Body: []rules.Point{{X: 5, Y: 1}, {X: 5, Y: 1}, {X: 5, Y: 1}}, Health: 100},
{ID: "5", Body: []rules.Point{{X: 5, Y: 9}, {X: 5, Y: 9}, {X: 5, Y: 9}}, Health: 100},
{ID: "6", Body: []rules.Point{{X: 9, Y: 1}, {X: 9, Y: 1}, {X: 9, Y: 1}}, Health: 100},
{ID: "7", Body: []rules.Point{{X: 9, Y: 5}, {X: 9, Y: 5}, {X: 9, Y: 5}}, Health: 100},
{ID: "8", Body: []rules.Point{{X: 9, Y: 9}, {X: 9, Y: 9}, {X: 9, Y: 9}}, Health: 100},
},
Food: []rules.Point{
{X: 0, Y: 2},
{X: 0, Y: 4},
{X: 0, Y: 8},
{X: 4, Y: 0},
{X: 4, Y: 10},
{X: 8, Y: 0},
{X: 10, Y: 4},
{X: 8, Y: 10},
{X: 5, Y: 5},
},
Hazards: []rules.Point{},
},
nil,
},
{
"full 11x11 max",
&rules.BoardState{
Width: 11,
Height: 11,
Snakes: generateSnakes(8),
Food: []rules.Point{},
Hazards: []rules.Point{},
},
rules.MaxRand,
&rules.BoardState{
Width: 11,
Height: 11,
Snakes: []rules.Snake{
{ID: "1", Body: []rules.Point{{X: 1, Y: 5}, {X: 1, Y: 5}, {X: 1, Y: 5}}, Health: 100},
{ID: "2", Body: []rules.Point{{X: 1, Y: 9}, {X: 1, Y: 9}, {X: 1, Y: 9}}, Health: 100},
{ID: "3", Body: []rules.Point{{X: 5, Y: 1}, {X: 5, Y: 1}, {X: 5, Y: 1}}, Health: 100},
{ID: "4", Body: []rules.Point{{X: 5, Y: 9}, {X: 5, Y: 9}, {X: 5, Y: 9}}, Health: 100},
{ID: "5", Body: []rules.Point{{X: 9, Y: 1}, {X: 9, Y: 1}, {X: 9, Y: 1}}, Health: 100},
{ID: "6", Body: []rules.Point{{X: 9, Y: 5}, {X: 9, Y: 5}, {X: 9, Y: 5}}, Health: 100},
{ID: "7", Body: []rules.Point{{X: 9, Y: 9}, {X: 9, Y: 9}, {X: 9, Y: 9}}, Health: 100},
{ID: "8", Body: []rules.Point{{X: 1, Y: 1}, {X: 1, Y: 1}, {X: 1, Y: 1}}, Health: 100},
},
Food: []rules.Point{
{X: 0, Y: 6},
{X: 2, Y: 10},
{X: 6, Y: 0},
{X: 6, Y: 10},
{X: 10, Y: 2},
{X: 10, Y: 6},
{X: 10, Y: 8},
{X: 2, Y: 0},
{X: 5, Y: 5},
},
Hazards: []rules.Point{},
},
nil,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
nextBoardState := rules.NewBoardState(test.initialBoardState.Width, test.initialBoardState.Height)
editor := NewBoardStateEditor(nextBoardState, test.rand)
err := m.SetupBoard(test.initialBoardState, settings, editor)
if test.err != nil {
require.Equal(t, test.err, err)
} else {
require.Equal(t, test.expected, nextBoardState)
}
})
}
}
func TestStandardMapUpdateBoard(t *testing.T) {
m := StandardMap{}
tests := []struct {
name string
initialBoardState *rules.BoardState
settings rules.Settings
rand rules.Rand
expected *rules.BoardState
}{
{
"empty no food",
rules.NewBoardState(2, 2),
rules.Settings{
FoodSpawnChance: 0,
MinimumFood: 0,
},
rules.MinRand,
&rules.BoardState{
Width: 2,
Height: 2,
Snakes: []rules.Snake{},
Food: []rules.Point{},
Hazards: []rules.Point{},
},
},
{
"empty MinimumFood",
rules.NewBoardState(2, 2),
rules.Settings{
FoodSpawnChance: 0,
MinimumFood: 2,
},
rules.MinRand,
&rules.BoardState{
Width: 2,
Height: 2,
Snakes: []rules.Snake{},
Food: []rules.Point{{X: 0, Y: 0}, {X: 0, Y: 1}},
Hazards: []rules.Point{},
},
},
{
"not empty MinimumFood",
&rules.BoardState{
Width: 2,
Height: 2,
Snakes: []rules.Snake{},
Food: []rules.Point{{X: 0, Y: 1}},
Hazards: []rules.Point{},
},
rules.Settings{
FoodSpawnChance: 0,
MinimumFood: 2,
},
rules.MinRand,
&rules.BoardState{
Width: 2,
Height: 2,
Snakes: []rules.Snake{},
Food: []rules.Point{{X: 0, Y: 1}, {X: 0, Y: 0}},
Hazards: []rules.Point{},
},
},
{
"empty FoodSpawnChance inactive",
rules.NewBoardState(2, 2),
rules.Settings{
FoodSpawnChance: 50,
MinimumFood: 0,
},
rules.MinRand,
&rules.BoardState{
Width: 2,
Height: 2,
Snakes: []rules.Snake{},
Food: []rules.Point{},
Hazards: []rules.Point{},
},
},
{
"empty FoodSpawnChance active",
rules.NewBoardState(2, 2),
rules.Settings{
FoodSpawnChance: 50,
MinimumFood: 0,
},
rules.MaxRand,
&rules.BoardState{
Width: 2,
Height: 2,
Snakes: []rules.Snake{},
Food: []rules.Point{{X: 0, Y: 1}},
Hazards: []rules.Point{},
},
},
{
"not empty FoodSpawnChance active",
&rules.BoardState{
Width: 2,
Height: 2,
Snakes: []rules.Snake{},
Food: []rules.Point{{X: 0, Y: 0}},
Hazards: []rules.Point{},
},
rules.Settings{
FoodSpawnChance: 50,
MinimumFood: 0,
},
rules.MaxRand,
&rules.BoardState{
Width: 2,
Height: 2,
Snakes: []rules.Snake{},
Food: []rules.Point{{X: 0, Y: 0}, {X: 1, Y: 0}},
Hazards: []rules.Point{},
},
},
{
"not empty FoodSpawnChance no room",
&rules.BoardState{
Width: 2,
Height: 2,
Snakes: []rules.Snake{},
Food: []rules.Point{{X: 0, Y: 0}, {X: 0, Y: 1}, {X: 1, Y: 0}, {X: 1, Y: 1}},
Hazards: []rules.Point{},
},
rules.Settings{
FoodSpawnChance: 50,
MinimumFood: 0,
},
rules.MaxRand,
&rules.BoardState{
Width: 2,
Height: 2,
Snakes: []rules.Snake{},
Food: []rules.Point{{X: 0, Y: 0}, {X: 0, Y: 1}, {X: 1, Y: 0}, {X: 1, Y: 1}},
Hazards: []rules.Point{},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
nextBoardState := test.initialBoardState.Clone()
editor := NewBoardStateEditor(nextBoardState, test.rand)
err := m.UpdateBoard(test.initialBoardState.Clone(), test.settings, editor)
require.NoError(t, err)
require.Equal(t, test.expected, nextBoardState)
})
}
}
func generateSnakes(n int) []rules.Snake {
var snakes []rules.Snake
for i := 0; i < n; i++ {
snakes = append(snakes, rules.Snake{
ID: fmt.Sprint(i + 1),
})
}
return snakes
}

View file

@ -2,9 +2,6 @@ package rules
import "fmt"
// StageRegistry is a mapping of stage names to stage functions
type StageRegistry map[string]StageFunc
const (
StageSpawnFoodStandard = "spawn_food.standard"
StageGameOverStandard = "game_over.standard"
@ -46,6 +43,17 @@ var globalRegistry = StageRegistry{
StageModifySnakesShareAttributes: ShareAttributesSquad,
}
// StageFunc represents a single stage of an ordered pipeline and applies custom logic to the board state each turn.
// It is expected to modify the boardState directly.
// The return values are a boolean (to indicate whether the game has ended as a result of the stage)
// and an error if any errors occurred during the stage.
//
// Errors should be treated as meaning the stage failed and the board state is now invalid.
type StageFunc func(*BoardState, Settings, []SnakeMove) (bool, error)
// StageRegistry is a mapping of stage names to stage functions
type StageRegistry map[string]StageFunc
// RegisterPipelineStage adds a stage to the registry.
// If a stage has already been mapped it will be overwritten by the newly
// registered function.

56
rand.go Normal file
View file

@ -0,0 +1,56 @@
package rules
import "math/rand"
type Rand interface {
Intn(n int) int
Shuffle(n int, swap func(i, j int))
}
// A Rand implementation that just uses the global math/rand generator.
var GlobalRand globalRand
type globalRand struct{}
func (globalRand) Intn(n int) int {
return rand.Intn(n)
}
func (globalRand) Shuffle(n int, swap func(i, j int)) {
rand.Shuffle(n, swap)
}
// For testing purposes
// A Rand implementation that always returns the minimum value for any method.
var MinRand minRand
type minRand struct{}
func (minRand) Intn(n int) int {
return 0
}
func (minRand) Shuffle(n int, swap func(i, j int)) {
// no shuffling
}
// A Rand implementation that always returns the maximum value for any method.
var MaxRand maxRand
type maxRand struct{}
func (maxRand) Intn(n int) int {
return n - 1
}
func (maxRand) Shuffle(n int, swap func(i, j int)) {
// rotate by one element so every element is moved
if n < 2 {
return
}
for i := 0; i < n-2; i++ {
swap(i, i+1)
}
swap(n-2, n-1)
}

View file

@ -4,67 +4,66 @@ import (
"strconv"
)
type RulesetError string
type Ruleset interface {
Name() string
ModifyInitialBoardState(initialState *BoardState) (*BoardState, error)
CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error)
IsGameOver(state *BoardState) (bool, error)
// Settings provides the game settings that are relevant to the ruleset.
Settings() Settings
}
func (err RulesetError) Error() string { return string(err) }
type SnakeMove struct {
ID string
Move string
}
const (
MoveUp = "up"
MoveDown = "down"
MoveRight = "right"
MoveLeft = "left"
// Settings contains all settings relevant to a game.
// It is used by game logic to take a previous game state and produce a next game state.
type Settings struct {
FoodSpawnChance int32 `json:"foodSpawnChance"`
MinimumFood int32 `json:"minimumFood"`
HazardDamagePerTurn int32 `json:"hazardDamagePerTurn"`
HazardMap string `json:"hazardMap"`
HazardMapAuthor string `json:"hazardMapAuthor"`
RoyaleSettings RoyaleSettings `json:"royale"`
SquadSettings SquadSettings `json:"squad"`
BoardSizeSmall = 7
BoardSizeMedium = 11
BoardSizeLarge = 19
rand Rand
}
SnakeMaxHealth = 100
SnakeStartSize = 3
func (settings Settings) Rand() Rand {
// Default to global random number generator if none is set.
if settings.rand == nil {
return GlobalRand
}
return settings.rand
}
// bvanvugt - TODO: Just return formatted strings instead of codes?
NotEliminated = ""
EliminatedByCollision = "snake-collision"
EliminatedBySelfCollision = "snake-self-collision"
EliminatedByOutOfHealth = "out-of-health"
EliminatedByHeadToHeadCollision = "head-collision"
EliminatedByOutOfBounds = "wall-collision"
EliminatedBySquad = "squad-eliminated"
func (settings Settings) WithRand(rand Rand) Settings {
settings.rand = rand
return settings
}
// TODO - Error consts
ErrorTooManySnakes = RulesetError("too many snakes for fixed start positions")
ErrorNoRoomForSnake = RulesetError("not enough space to place snake")
ErrorNoRoomForFood = RulesetError("not enough space to place food")
ErrorNoMoveFound = RulesetError("move not provided for snake")
ErrorZeroLengthSnake = RulesetError("snake is length zero")
ErrorEmptyRegistry = RulesetError("empty registry")
ErrorNoStages = RulesetError("no stages")
ErrorStageNotFound = RulesetError("stage not found")
// RoyaleSettings contains settings that are specific to the "royale" game mode
type RoyaleSettings struct {
seed int64
ShrinkEveryNTurns int32 `json:"shrinkEveryNTurns"`
}
// Ruleset / game type names
GameTypeConstrictor = "constrictor"
GameTypeRoyale = "royale"
GameTypeSolo = "solo"
GameTypeSquad = "squad"
GameTypeStandard = "standard"
GameTypeWrapped = "wrapped"
// Game creation parameter names
ParamGameType = "name"
ParamFoodSpawnChance = "foodSpawnChance"
ParamMinimumFood = "minimumFood"
ParamHazardDamagePerTurn = "damagePerTurn"
ParamHazardMap = "hazardMap"
ParamHazardMapAuthor = "hazardMapAuthor"
ParamShrinkEveryNTurns = "shrinkEveryNTurns"
ParamAllowBodyCollisions = "allowBodyCollisions"
ParamSharedElimination = "sharedElimination"
ParamSharedHealth = "sharedHealth"
ParamSharedLength = "sharedLength"
)
// SquadSettings contains settings that are specific to the "squad" game mode
type SquadSettings struct {
squadMap map[string]string
AllowBodyCollisions bool `json:"allowBodyCollisions"`
SharedElimination bool `json:"sharedElimination"`
SharedHealth bool `json:"sharedHealth"`
SharedLength bool `json:"sharedLength"`
}
type rulesetBuilder struct {
params map[string]string // game customisation parameters
seed int64 // used for random events in games
rand Rand // used for random number generation
squads map[string]string // Snake ID -> Squad Name
}
@ -93,12 +92,17 @@ func (rb *rulesetBuilder) WithParams(params map[string]string) *rulesetBuilder {
return rb
}
// WithSeed sets the seed used for randomisation by certain game modes.
// Deprecated: WithSeed sets the seed used for randomisation by certain game modes.
func (rb *rulesetBuilder) WithSeed(seed int64) *rulesetBuilder {
rb.seed = seed
return rb
}
func (rb *rulesetBuilder) WithRand(rand Rand) *rulesetBuilder {
rb.rand = rand
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 {
@ -185,6 +189,7 @@ func (rb rulesetBuilder) PipelineRuleset(name string, p Pipeline) PipelineRulese
SharedHealth: paramsBool(rb.params, ParamSharedHealth, false),
SharedLength: paramsBool(rb.params, ParamSharedLength, false),
},
rand: rb.rand,
},
}
}
@ -212,69 +217,6 @@ func paramsInt32(params map[string]string, paramName string, defaultValue int32)
return defaultValue
}
type Point struct {
X int32
Y int32
}
type Snake struct {
ID string
Body []Point
Health int32
EliminatedCause string
EliminatedOnTurn int32
EliminatedBy string
}
type SnakeMove struct {
ID string
Move string
}
type Ruleset interface {
Name() string
ModifyInitialBoardState(initialState *BoardState) (*BoardState, error)
CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error)
IsGameOver(state *BoardState) (bool, error)
// Settings provides the game settings that are relevant to the ruleset.
Settings() Settings
}
// Settings contains all settings relevant to a game.
// It is used by game logic to take a previous game state and produce a next game state.
type Settings struct {
FoodSpawnChance int32 `json:"foodSpawnChance"`
MinimumFood int32 `json:"minimumFood"`
HazardDamagePerTurn int32 `json:"hazardDamagePerTurn"`
HazardMap string `json:"hazardMap"`
HazardMapAuthor string `json:"hazardMapAuthor"`
RoyaleSettings RoyaleSettings `json:"royale"`
SquadSettings SquadSettings `json:"squad"`
}
// RoyaleSettings contains settings that are specific to the "royale" game mode
type RoyaleSettings struct {
seed int64
ShrinkEveryNTurns int32 `json:"shrinkEveryNTurns"`
}
// SquadSettings contains settings that are specific to the "squad" game mode
type SquadSettings struct {
squadMap map[string]string
AllowBodyCollisions bool `json:"allowBodyCollisions"`
SharedElimination bool `json:"sharedElimination"`
SharedHealth bool `json:"sharedHealth"`
SharedLength bool `json:"sharedLength"`
}
// StageFunc represents a single stage of an ordered pipeline and applies custom logic to the board state each turn.
// It is expected to modify the boardState directly.
// The return values are a boolean (to indicate whether the game has ended as a result of the stage)
// and an error if any errors occurred during the stage.
//
// Errors should be treated as meaning the stage failed and the board state is now invalid.
type StageFunc func(*BoardState, Settings, []SnakeMove) (bool, error)
// PipelineRuleset groups the Pipeline and Ruleset methods.
// It is intended to facilitate a transition from Ruleset legacy code to Pipeline code.
type PipelineRuleset interface {

View file

@ -393,10 +393,10 @@ func SpawnFoodStandard(b *BoardState, settings Settings, moves []SnakeMove) (boo
}
numCurrentFood := int32(len(b.Food))
if numCurrentFood < settings.MinimumFood {
return false, PlaceFoodRandomly(b, settings.MinimumFood-numCurrentFood)
return false, PlaceFoodRandomly(GlobalRand, b, settings.MinimumFood-numCurrentFood)
}
if settings.FoodSpawnChance > 0 && int32(rand.Intn(100)) < settings.FoodSpawnChance {
return false, PlaceFoodRandomly(b, 1)
return false, PlaceFoodRandomly(GlobalRand, b, 1)
}
return false, nil
}

View file

@ -15,7 +15,7 @@ func TestStandardRulesetInterface(t *testing.T) {
func TestSanity(t *testing.T) {
r := StandardRuleset{}
state, err := CreateDefaultBoardState(0, 0, []string{})
state, err := CreateDefaultBoardState(MaxRand, 0, 0, []string{})
require.NoError(t, err)
require.NotNil(t, state)