DEV 1303: Add empty and royale maps and update game map interface (#72)

* move random generator into Settings

* add empty and royale maps

* place snakes on either cardinal or corner positions first
This commit is contained in:
Rob O'Dwyer 2022-05-17 15:45:56 -07:00 committed by GitHub
parent 6fa2da2f01
commit e94d758a9b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 479 additions and 52 deletions

49
maps/empty.go Normal file
View file

@ -0,0 +1,49 @@
package maps
import (
"github.com/BattlesnakeOfficial/rules"
)
type EmptyMap struct{}
func init() {
globalRegistry.RegisterMap("empty", EmptyMap{})
}
func (m EmptyMap) ID() string {
return "empty"
}
func (m EmptyMap) Meta() Metadata {
return Metadata{
Name: "Empty",
Description: "Default snake placement with no food",
Author: "Battlesnake",
}
}
func (m EmptyMap) SetupBoard(initialBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
rand := settings.GetRand(0)
snakeIDs := make([]string, 0, len(initialBoardState.Snakes))
for _, snake := range initialBoardState.Snakes {
snakeIDs = append(snakeIDs, snake.ID)
}
tempBoardState := rules.NewBoardState(initialBoardState.Width, initialBoardState.Height)
err := rules.PlaceSnakesAutomatically(rand, tempBoardState, snakeIDs)
if err != nil {
return err
}
// Copy snakes from temp board state
for _, snake := range tempBoardState.Snakes {
editor.PlaceSnake(snake.ID, snake.Body, snake.Health)
}
return nil
}
func (m EmptyMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}

164
maps/empty_test.go Normal file
View file

@ -0,0 +1,164 @@
package maps
import (
"testing"
"github.com/BattlesnakeOfficial/rules"
"github.com/stretchr/testify/require"
)
func TestEmptyMapInterface(t *testing.T) {
var _ GameMap = EmptyMap{}
}
func TestEmptyMapSetupBoard(t *testing.T) {
m := EmptyMap{}
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{},
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: 9}, {X: 1, Y: 9}, {X: 1, Y: 9}}, Health: 100},
{ID: "3", Body: []rules.Point{{X: 9, Y: 1}, {X: 9, Y: 1}, {X: 9, Y: 1}}, Health: 100},
{ID: "4", Body: []rules.Point{{X: 9, Y: 9}, {X: 9, Y: 9}, {X: 9, Y: 9}}, Health: 100},
{ID: "5", Body: []rules.Point{{X: 1, Y: 5}, {X: 1, Y: 5}, {X: 1, Y: 5}}, Health: 100},
{ID: "6", Body: []rules.Point{{X: 5, Y: 1}, {X: 5, Y: 1}, {X: 5, Y: 1}}, Health: 100},
{ID: "7", Body: []rules.Point{{X: 5, Y: 9}, {X: 5, Y: 9}, {X: 5, Y: 9}}, Health: 100},
{ID: "8", Body: []rules.Point{{X: 9, Y: 5}, {X: 9, Y: 5}, {X: 9, Y: 5}}, Health: 100},
},
Food: []rules.Point{},
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: 5, Y: 1}, {X: 5, Y: 1}, {X: 5, Y: 1}}, Health: 100},
{ID: "2", Body: []rules.Point{{X: 5, Y: 9}, {X: 5, Y: 9}, {X: 5, Y: 9}}, Health: 100},
{ID: "3", Body: []rules.Point{{X: 9, Y: 5}, {X: 9, Y: 5}, {X: 9, Y: 5}}, Health: 100},
{ID: "4", Body: []rules.Point{{X: 1, Y: 5}, {X: 1, Y: 5}, {X: 1, Y: 5}}, Health: 100},
{ID: "5", Body: []rules.Point{{X: 1, Y: 9}, {X: 1, Y: 9}, {X: 1, 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: 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{},
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)
settings := settings.WithRand(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 TestEmptyMapUpdateBoard(t *testing.T) {
m := EmptyMap{}
initialBoardState := &rules.BoardState{
Width: 2,
Height: 2,
Snakes: []rules.Snake{},
Food: []rules.Point{{X: 0, Y: 0}},
Hazards: []rules.Point{},
}
settings := rules.Settings{
FoodSpawnChance: 50,
MinimumFood: 2,
}.WithRand(rules.MaxRand)
nextBoardState := initialBoardState.Clone()
err := m.UpdateBoard(initialBoardState.Clone(), settings, NewBoardStateEditor(nextBoardState))
require.NoError(t, err)
require.Equal(t, &rules.BoardState{
Width: 2,
Height: 2,
Snakes: []rules.Snake{},
Food: []rules.Point{{X: 0, Y: 0}},
Hazards: []rules.Point{},
}, nextBoardState)
}

View file

@ -24,9 +24,6 @@ type Metadata struct {
// 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()
@ -52,18 +49,14 @@ type Editor interface {
// An Editor backed by a BoardState.
type BoardStateEditor struct {
*rules.BoardState
rand rules.Rand
}
func NewBoardStateEditor(boardState *rules.BoardState, rand rules.Rand) *BoardStateEditor {
func NewBoardStateEditor(boardState *rules.BoardState) *BoardStateEditor {
return &BoardStateEditor{
BoardState: boardState,
rand: rand,
}
}
func (editor *BoardStateEditor) Random() rules.Rand { return editor.rand }
func (editor *BoardStateEditor) ClearFood() {
editor.Food = []rules.Point{}
}

View file

@ -13,7 +13,7 @@ func SetupBoard(mapID string, settings rules.Settings, width, height int, snakeI
return nil, err
}
editor := NewBoardStateEditor(boardState, settings.Rand())
editor := NewBoardStateEditor(boardState)
err = gameMap.SetupBoard(boardState, settings, editor)
if err != nil {
@ -31,7 +31,7 @@ func UpdateBoard(mapID string, previousBoardState *rules.BoardState, settings ru
}
nextBoardState := previousBoardState.Clone()
editor := NewBoardStateEditor(nextBoardState, settings.Rand())
editor := NewBoardStateEditor(nextBoardState)
err = gameMap.UpdateBoard(previousBoardState, settings, editor)
if err != nil {

74
maps/royale.go Normal file
View file

@ -0,0 +1,74 @@
package maps
import (
"errors"
"github.com/BattlesnakeOfficial/rules"
)
type RoyaleHazardsMap struct{}
func init() {
globalRegistry.RegisterMap("royale", RoyaleHazardsMap{})
}
func (m RoyaleHazardsMap) ID() string {
return "royale"
}
func (m RoyaleHazardsMap) Meta() Metadata {
return Metadata{
Name: "Royale",
Description: "A map where hazards are generated every N turns",
Author: "Battlesnake",
}
}
func (m RoyaleHazardsMap) SetupBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return StandardMap{}.SetupBoard(lastBoardState, settings, editor)
}
func (m RoyaleHazardsMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
// Royale uses the current turn to generate hazards, not the previous turn that's in the board state
turn := lastBoardState.Turn + 1
if settings.RoyaleSettings.ShrinkEveryNTurns < 1 {
return errors.New("royale game can't shrink more frequently than every turn")
}
if turn < settings.RoyaleSettings.ShrinkEveryNTurns {
return nil
}
// Reset hazards every turn and re-generate them
editor.ClearHazards()
// Get random generator for turn zero, because we're regenerating all hazards every time.
randGenerator := settings.GetRand(0)
numShrinks := turn / settings.RoyaleSettings.ShrinkEveryNTurns
minX, maxX := int32(0), lastBoardState.Width-1
minY, maxY := int32(0), lastBoardState.Height-1
for i := int32(0); i < numShrinks; i++ {
switch randGenerator.Intn(4) {
case 0:
minX += 1
case 1:
maxX -= 1
case 2:
minY += 1
case 3:
maxY -= 1
}
}
for x := int32(0); x < lastBoardState.Width; x++ {
for y := int32(0); y < lastBoardState.Height; y++ {
if x < minX || x > maxX || y < minY || y > maxY {
editor.AddHazard(rules.Point{X: x, Y: y})
}
}
}
return nil
}

View file

@ -23,12 +23,14 @@ func (m StandardMap) Meta() Metadata {
}
func (m StandardMap) SetupBoard(initialBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
rand := settings.GetRand(0)
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)
tempBoardState, err := rules.CreateDefaultBoardState(rand, initialBoardState.Width, initialBoardState.Height, snakeIDs)
if err != nil {
return err
}
@ -47,30 +49,31 @@ func (m StandardMap) SetupBoard(initialBoardState *rules.BoardState, settings ru
}
func (m StandardMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
rand := settings.GetRand(lastBoardState.Turn)
minFood := int(settings.MinimumFood)
foodSpawnChance := int(settings.FoodSpawnChance)
numCurrentFood := len(lastBoardState.Food)
if numCurrentFood < minFood {
placeFoodRandomly(lastBoardState, editor, minFood-numCurrentFood)
placeFoodRandomly(rand, lastBoardState, editor, minFood-numCurrentFood)
return nil
}
if foodSpawnChance > 0 && (100-editor.Random().Intn(100)) < foodSpawnChance {
placeFoodRandomly(lastBoardState, editor, 1)
if foodSpawnChance > 0 && (100-rand.Intn(100)) < foodSpawnChance {
placeFoodRandomly(rand, lastBoardState, editor, 1)
return nil
}
return nil
}
func placeFoodRandomly(b *rules.BoardState, editor Editor, n int) {
func placeFoodRandomly(rand rules.Rand, 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) {
rand.Shuffle(len(unoccupiedPoints), func(i int, j int) {
unoccupiedPoints[i], unoccupiedPoints[j] = unoccupiedPoints[j], unoccupiedPoints[i]
})

View file

@ -78,23 +78,23 @@ func TestStandardMapSetupBoard(t *testing.T) {
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},
{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: 9, Y: 1}, {X: 9, Y: 1}, {X: 9, Y: 1}}, Health: 100},
{ID: "4", Body: []rules.Point{{X: 9, Y: 9}, {X: 9, Y: 9}, {X: 9, Y: 9}}, Health: 100},
{ID: "5", Body: []rules.Point{{X: 1, Y: 5}, {X: 1, Y: 5}, {X: 1, Y: 5}}, Health: 100},
{ID: "6", Body: []rules.Point{{X: 5, Y: 1}, {X: 5, Y: 1}, {X: 5, Y: 1}}, Health: 100},
{ID: "7", Body: []rules.Point{{X: 5, Y: 9}, {X: 5, Y: 9}, {X: 5, Y: 9}}, Health: 100},
{ID: "8", Body: []rules.Point{{X: 9, Y: 5}, {X: 9, Y: 5}, {X: 9, Y: 5}}, Health: 100},
},
Food: []rules.Point{
{X: 0, Y: 2},
{X: 0, Y: 4},
{X: 0, Y: 8},
{X: 8, Y: 0},
{X: 8, Y: 10},
{X: 0, Y: 4},
{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{},
@ -115,22 +115,22 @@ func TestStandardMapSetupBoard(t *testing.T) {
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: "1", Body: []rules.Point{{X: 5, Y: 1}, {X: 5, Y: 1}, {X: 5, Y: 1}}, Health: 100},
{ID: "2", Body: []rules.Point{{X: 5, Y: 9}, {X: 5, Y: 9}, {X: 5, Y: 9}}, Health: 100},
{ID: "3", Body: []rules.Point{{X: 9, Y: 5}, {X: 9, Y: 5}, {X: 9, Y: 5}}, Health: 100},
{ID: "4", Body: []rules.Point{{X: 1, Y: 5}, {X: 1, Y: 5}, {X: 1, Y: 5}}, Health: 100},
{ID: "5", Body: []rules.Point{{X: 1, Y: 9}, {X: 1, Y: 9}, {X: 1, 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: 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: 0, Y: 6},
{X: 2, Y: 10},
{X: 10, Y: 2},
{X: 10, Y: 8},
{X: 2, Y: 0},
{X: 5, Y: 5},
@ -143,14 +143,15 @@ func TestStandardMapSetupBoard(t *testing.T) {
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)
editor := NewBoardStateEditor(nextBoardState)
settings := settings.WithRand(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)
require.Equalf(t, test.expected, nextBoardState, "%#v", nextBoardState.Food)
}
})
}
@ -301,9 +302,10 @@ func TestStandardMapUpdateBoard(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
nextBoardState := test.initialBoardState.Clone()
editor := NewBoardStateEditor(nextBoardState, test.rand)
settings := test.settings.WithRand(test.rand)
editor := NewBoardStateEditor(nextBoardState)
err := m.UpdateBoard(test.initialBoardState.Clone(), test.settings, editor)
err := m.UpdateBoard(test.initialBoardState.Clone(), settings, editor)
require.NoError(t, err)
require.Equal(t, test.expected, nextBoardState)