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

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
}