DEV-1761: New rules API (#118)

* DEV-1761: Clean up Ruleset interface (#115)

* remove legacy ruleset types and simplify ruleset interface

* remove unnecessary settings argument from Ruleset interface

* decouple rules.Settings from client API and store settings as strings

* DEV 1761: Add new BoardState and Point fields (#117)

* add Point.TTL, Point.Value, GameState and PointState to BoardState

* allow maps to access BoardState.GameState,PointState

* add PreUpdateBoard and refactor snail_mode with it

* fix bug where an extra turn was printed to the console

* fix formatting

* fix lint errors

Co-authored-by: JonathanArns <jonathan.arns@googlemail.com>
This commit is contained in:
Rob O'Dwyer 2022-10-28 16:49:49 -07:00 committed by GitHub
parent 639362ef46
commit 82e1999126
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 1349 additions and 1610 deletions

View file

@ -2,6 +2,9 @@ package rules
import "fmt"
// BoardState represents the internal state of a game board.
// NOTE: use NewBoardState to construct these to ensure fields are initialized
// correctly and that tests are resilient to changes to this type.
type BoardState struct {
Turn int
Height int
@ -9,15 +12,26 @@ type BoardState struct {
Food []Point
Snakes []Snake
Hazards []Point
// Generic game-level state for maps and rules stages to persist data between turns.
GameState map[string]string
// Numeric state keyed to specific points, also persisted between turns.
PointState map[Point]int
}
type Point struct {
X int
Y int
X int `json:"X"`
Y int `json:"Y"`
TTL int `json:"TTL,omitempty"`
Value int `json:"Value,omitempty"`
}
// Makes it easier to copy sample points out of Go logs and test failures.
func (p Point) GoString() string {
if p.TTL != 0 || p.Value != 0 {
return fmt.Sprintf("{X:%d, Y:%d, TTL:%d, Value:%d}", p.X, p.Y, p.TTL, p.Value)
}
return fmt.Sprintf("{X:%d, Y:%d}", p.X, p.Y)
}
@ -39,6 +53,8 @@ func NewBoardState(width, height int) *BoardState {
Food: []Point{},
Snakes: []Snake{},
Hazards: []Point{},
GameState: map[string]string{},
PointState: map[Point]int{},
}
}
@ -51,6 +67,14 @@ func (prevState *BoardState) Clone() *BoardState {
Food: append([]Point{}, prevState.Food...),
Snakes: make([]Snake, len(prevState.Snakes)),
Hazards: append([]Point{}, prevState.Hazards...),
GameState: make(map[string]string, len(prevState.GameState)),
PointState: make(map[Point]int, len(prevState.PointState)),
}
for key, value := range prevState.GameState {
nextState.GameState[key] = value
}
for key, value := range prevState.PointState {
nextState.PointState[key] = value
}
for i := 0; i < len(prevState.Snakes); i++ {
nextState.Snakes[i].ID = prevState.Snakes[i].ID
@ -63,6 +87,42 @@ func (prevState *BoardState) Clone() *BoardState {
return nextState
}
// Builder method to set Turn and return the modified BoardState.
func (state *BoardState) WithTurn(turn int) *BoardState {
state.Turn = turn
return state
}
// Builder method to set Food and return the modified BoardState.
func (state *BoardState) WithFood(food []Point) *BoardState {
state.Food = food
return state
}
// Builder method to set Hazards and return the modified BoardState.
func (state *BoardState) WithHazards(hazards []Point) *BoardState {
state.Hazards = hazards
return state
}
// Builder method to set Snakes and return the modified BoardState.
func (state *BoardState) WithSnakes(snakes []Snake) *BoardState {
state.Snakes = snakes
return state
}
// Builder method to set State and return the modified BoardState.
func (state *BoardState) WithGameState(gameState map[string]string) *BoardState {
state.GameState = gameState
return state
}
// Builder method to set PointState and return the modified BoardState.
func (state *BoardState) WithPointState(pointState map[Point]int) *BoardState {
state.PointState = pointState
return state
}
// CreateDefaultBoardState is a convenience function for fully initializing a
// "default" board state with snakes and food.
// In a real game, the engine may generate the board without calling this
@ -120,16 +180,16 @@ func PlaceSnakesFixed(rand Rand, b *BoardState, snakeIDs []string) error {
// Create start 8 points
mn, md, mx := 1, (b.Width-1)/2, b.Width-2
cornerPoints := []Point{
{mn, mn},
{mn, mx},
{mx, mn},
{mx, mx},
{X: mn, Y: mn},
{X: mn, Y: mx},
{X: mx, Y: mn},
{X: mx, Y: mx},
}
cardinalPoints := []Point{
{mn, md},
{md, mn},
{md, mx},
{mx, md},
{X: mn, Y: md},
{X: md, Y: mn},
{X: md, Y: mx},
{X: mx, Y: md},
}
// Sanity check
@ -325,7 +385,7 @@ func PlaceFoodAutomatically(rand Rand, b *BoardState) error {
// Deprecated: will be replaced by maps.PlaceFoodFixed
func PlaceFoodFixed(rand Rand, b *BoardState) error {
centerCoord := Point{(b.Width - 1) / 2, (b.Height - 1) / 2}
centerCoord := Point{X: (b.Width - 1) / 2, Y: (b.Height - 1) / 2}
isSmallBoard := b.Width*b.Height < BoardSizeMedium*BoardSizeMedium
// Up to 4 snakes can be placed such that food is nearby on small boards.
@ -335,10 +395,10 @@ func PlaceFoodFixed(rand Rand, b *BoardState) error {
for i := 0; i < len(b.Snakes); i++ {
snakeHead := b.Snakes[i].Body[0]
possibleFoodLocations := []Point{
{snakeHead.X - 1, snakeHead.Y - 1},
{snakeHead.X - 1, snakeHead.Y + 1},
{snakeHead.X + 1, snakeHead.Y - 1},
{snakeHead.X + 1, snakeHead.Y + 1},
{X: snakeHead.X - 1, Y: snakeHead.Y - 1},
{X: snakeHead.X - 1, Y: snakeHead.Y + 1},
{X: snakeHead.X + 1, Y: snakeHead.Y - 1},
{X: snakeHead.X + 1, Y: snakeHead.Y + 1},
}
// Remove any invalid/unwanted positions
@ -448,7 +508,7 @@ func GetEvenUnoccupiedPoints(b *BoardState) []Point {
// removeCenterCoord filters out the board's center point from a list of points.
func removeCenterCoord(b *BoardState, points []Point) []Point {
centerCoord := Point{(b.Width - 1) / 2, (b.Height - 1) / 2}
centerCoord := Point{X: (b.Width - 1) / 2, Y: (b.Height - 1) / 2}
var noCenterPoints []Point
for _, p := range points {
if p != centerCoord {

View file

@ -8,6 +8,30 @@ import (
"github.com/stretchr/testify/require"
)
func TestBoardStateClone(t *testing.T) {
empty := &BoardState{}
require.Equal(t, NewBoardState(0, 0), empty.Clone())
full := NewBoardState(11, 11).
WithTurn(99).
WithFood([]Point{{X: 1, Y: 2, TTL: 10, Value: 100}}).
WithHazards([]Point{{X: 3, Y: 4, TTL: 5, Value: 50}}).
WithSnakes([]Snake{
{
ID: "1",
Body: []Point{{X: 1, Y: 2}},
Health: 99,
EliminatedCause: EliminatedByCollision,
EliminatedOnTurn: 45,
EliminatedBy: "2",
},
}).
WithGameState(map[string]string{"example": "game data"}).
WithPointState(map[Point]int{{X: 1, Y: 1}: 42})
require.Equal(t, full, full.Clone())
}
func TestDev1235(t *testing.T) {
// Small boards should no longer error and only get 1 food when num snakes > 4
state, err := CreateDefaultBoardState(MaxRand, BoardSizeSmall, BoardSizeSmall, []string{
@ -346,23 +370,23 @@ func TestPlaceSnake(t *testing.T) {
boardState := NewBoardState(BoardSizeSmall, BoardSizeSmall)
require.Empty(t, boardState.Snakes)
_ = PlaceSnake(boardState, "a", []Point{{0, 0}, {1, 0}, {1, 1}})
_ = PlaceSnake(boardState, "a", []Point{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 1, Y: 1}})
require.Len(t, boardState.Snakes, 1)
require.Equal(t, Snake{
ID: "a",
Body: []Point{{0, 0}, {1, 0}, {1, 1}},
Body: []Point{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 1, Y: 1}},
Health: SnakeMaxHealth,
EliminatedCause: NotEliminated,
EliminatedBy: "",
}, boardState.Snakes[0])
_ = PlaceSnake(boardState, "b", []Point{{0, 2}, {1, 2}, {3, 2}})
_ = PlaceSnake(boardState, "b", []Point{{X: 0, Y: 2}, {X: 1, Y: 2}, {X: 3, Y: 2}})
require.Len(t, boardState.Snakes, 2)
require.Equal(t, Snake{
ID: "b",
Body: []Point{{0, 2}, {1, 2}, {3, 2}},
Body: []Point{{X: 0, Y: 2}, {X: 1, Y: 2}, {X: 3, Y: 2}},
Health: SnakeMaxHealth,
EliminatedCause: NotEliminated,
EliminatedBy: "",
@ -411,9 +435,9 @@ func TestPlaceFood(t *testing.T) {
Width: BoardSizeSmall,
Height: BoardSizeSmall,
Snakes: []Snake{
{Body: []Point{{5, 1}}},
{Body: []Point{{5, 3}}},
{Body: []Point{{5, 5}}},
{Body: []Point{{X: 5, Y: 1}}},
{Body: []Point{{X: 5, Y: 3}}},
{Body: []Point{{X: 5, Y: 5}}},
},
},
4, // +1 because of fixed spawn locations
@ -423,14 +447,14 @@ func TestPlaceFood(t *testing.T) {
Width: BoardSizeMedium,
Height: BoardSizeMedium,
Snakes: []Snake{
{Body: []Point{{1, 1}}},
{Body: []Point{{1, 5}}},
{Body: []Point{{1, 9}}},
{Body: []Point{{5, 1}}},
{Body: []Point{{5, 9}}},
{Body: []Point{{9, 1}}},
{Body: []Point{{9, 5}}},
{Body: []Point{{9, 9}}},
{Body: []Point{{X: 1, Y: 1}}},
{Body: []Point{{X: 1, Y: 5}}},
{Body: []Point{{X: 1, Y: 9}}},
{Body: []Point{{X: 5, Y: 1}}},
{Body: []Point{{X: 5, Y: 9}}},
{Body: []Point{{X: 9, Y: 1}}},
{Body: []Point{{X: 9, Y: 5}}},
{Body: []Point{{X: 9, Y: 9}}},
},
},
9, // +1 because of fixed spawn locations
@ -440,12 +464,12 @@ func TestPlaceFood(t *testing.T) {
Width: BoardSizeLarge,
Height: BoardSizeLarge,
Snakes: []Snake{
{Body: []Point{{1, 1}}},
{Body: []Point{{1, 9}}},
{Body: []Point{{1, 17}}},
{Body: []Point{{17, 1}}},
{Body: []Point{{17, 9}}},
{Body: []Point{{17, 17}}},
{Body: []Point{{X: 1, Y: 1}}},
{Body: []Point{{X: 1, Y: 9}}},
{Body: []Point{{X: 1, Y: 17}}},
{Body: []Point{{X: 17, Y: 1}}},
{Body: []Point{{X: 17, Y: 9}}},
{Body: []Point{{X: 17, Y: 17}}},
},
},
7, // +1 because of fixed spawn locations
@ -478,7 +502,7 @@ func TestPlaceFoodFixed(t *testing.T) {
Width: BoardSizeSmall,
Height: BoardSizeSmall,
Snakes: []Snake{
{Body: []Point{{1, 3}}},
{Body: []Point{{X: 1, Y: 3}}},
},
},
},
@ -487,10 +511,10 @@ func TestPlaceFoodFixed(t *testing.T) {
Width: BoardSizeMedium,
Height: BoardSizeMedium,
Snakes: []Snake{
{Body: []Point{{1, 1}}},
{Body: []Point{{1, 5}}},
{Body: []Point{{9, 5}}},
{Body: []Point{{9, 9}}},
{Body: []Point{{X: 1, Y: 1}}},
{Body: []Point{{X: 1, Y: 5}}},
{Body: []Point{{X: 9, Y: 5}}},
{Body: []Point{{X: 9, Y: 9}}},
},
},
},
@ -499,14 +523,14 @@ func TestPlaceFoodFixed(t *testing.T) {
Width: BoardSizeLarge,
Height: BoardSizeLarge,
Snakes: []Snake{
{Body: []Point{{1, 1}}},
{Body: []Point{{1, 9}}},
{Body: []Point{{1, 17}}},
{Body: []Point{{9, 1}}},
{Body: []Point{{9, 17}}},
{Body: []Point{{17, 1}}},
{Body: []Point{{17, 9}}},
{Body: []Point{{17, 17}}},
{Body: []Point{{X: 1, Y: 1}}},
{Body: []Point{{X: 1, Y: 9}}},
{Body: []Point{{X: 1, Y: 17}}},
{Body: []Point{{X: 9, Y: 1}}},
{Body: []Point{{X: 9, Y: 17}}},
{Body: []Point{{X: 17, Y: 1}}},
{Body: []Point{{X: 17, Y: 9}}},
{Body: []Point{{X: 17, Y: 17}}},
},
},
},
@ -519,16 +543,16 @@ func TestPlaceFoodFixed(t *testing.T) {
require.NoError(t, err)
require.Equal(t, len(test.BoardState.Snakes)+1, len(test.BoardState.Food))
midPoint := Point{(test.BoardState.Width - 1) / 2, (test.BoardState.Height - 1) / 2}
midPoint := Point{X: (test.BoardState.Width - 1) / 2, Y: (test.BoardState.Height - 1) / 2}
// Make sure every snake has food within 2 moves of it
for _, snake := range test.BoardState.Snakes {
head := snake.Body[0]
bottomLeft := Point{head.X - 1, head.Y - 1}
topLeft := Point{head.X - 1, head.Y + 1}
bottomRight := Point{head.X + 1, head.Y - 1}
topRight := Point{head.X + 1, head.Y + 1}
bottomLeft := Point{X: head.X - 1, Y: head.Y - 1}
topLeft := Point{X: head.X - 1, Y: head.Y + 1}
bottomRight := Point{X: head.X + 1, Y: head.Y - 1}
topRight := Point{X: head.X + 1, Y: head.Y + 1}
foundFoodInTwoMoves := false
for _, food := range test.BoardState.Food {
@ -559,7 +583,7 @@ func TestPlaceFoodFixedNoRoom(t *testing.T) {
Width: 3,
Height: 3,
Snakes: []Snake{
{Body: []Point{{1, 1}}},
{Body: []Point{{X: 1, Y: 1}}},
},
Food: []Point{},
}
@ -572,10 +596,10 @@ func TestPlaceFoodFixedNoRoom_Corners(t *testing.T) {
Width: 7,
Height: 7,
Snakes: []Snake{
{Body: []Point{{1, 1}}},
{Body: []Point{{1, 5}}},
{Body: []Point{{5, 1}}},
{Body: []Point{{5, 5}}},
{Body: []Point{{X: 1, Y: 1}}},
{Body: []Point{{X: 1, Y: 5}}},
{Body: []Point{{X: 5, Y: 1}}},
{Body: []Point{{X: 5, Y: 5}}},
},
Food: []Point{},
}
@ -597,10 +621,10 @@ func TestPlaceFoodFixedNoRoom_Corners(t *testing.T) {
require.Error(t, err)
expectedFood := []Point{
{0, 2}, {2, 0}, // Snake @ {1, 1}
{0, 4}, {2, 6}, // Snake @ {1, 5}
{4, 0}, {6, 2}, // Snake @ {5, 1}
{4, 6}, {6, 4}, // Snake @ {5, 5}
{X: 0, Y: 2}, {X: 2, Y: 0}, // Snake @ {X: 1, Y: 1}
{X: 0, Y: 4}, {X: 2, Y: 6}, // Snake @ {X: 1, Y: 5}
{X: 4, Y: 0}, {X: 6, Y: 2}, // Snake @ {X: 5, Y: 1}
{X: 4, Y: 6}, {X: 6, Y: 4}, // Snake @ {X: 5, Y: 5}
}
sortPoints(expectedFood)
sortPoints(boardState.Food)
@ -612,10 +636,10 @@ func TestPlaceFoodFixedNoRoom_Cardinal(t *testing.T) {
Width: 11,
Height: 11,
Snakes: []Snake{
{Body: []Point{{1, 5}}},
{Body: []Point{{5, 1}}},
{Body: []Point{{5, 9}}},
{Body: []Point{{9, 5}}},
{Body: []Point{{X: 1, Y: 5}}},
{Body: []Point{{X: 5, Y: 1}}},
{Body: []Point{{X: 5, Y: 9}}},
{Body: []Point{{X: 9, Y: 5}}},
},
Food: []Point{},
}
@ -637,10 +661,10 @@ func TestPlaceFoodFixedNoRoom_Cardinal(t *testing.T) {
require.Error(t, err)
expectedFood := []Point{
{0, 4}, {0, 6}, // Snake @ {1, 5}
{4, 0}, {6, 0}, // Snake @ {5, 1}
{4, 10}, {6, 10}, // Snake @ {5, 9}
{10, 4}, {10, 6}, // Snake @ {9, 5}
{X: 0, Y: 4}, {X: 0, Y: 6}, // Snake @ {X: 1, Y: 5}
{X: 4, Y: 0}, {X: 6, Y: 0}, // Snake @ {X: 5, Y: 1}
{X: 4, Y: 10}, {X: 6, Y: 10}, // Snake @ {X: 5, Y: 9}
{X: 10, Y: 4}, {X: 10, Y: 6}, // Snake @ {X: 9, Y: 5}
}
sortPoints(expectedFood)
sortPoints(boardState.Food)
@ -653,15 +677,15 @@ func TestGetDistanceBetweenPoints(t *testing.T) {
B Point
Expected int
}{
{Point{0, 0}, Point{0, 0}, 0},
{Point{0, 0}, Point{1, 0}, 1},
{Point{0, 0}, Point{0, 1}, 1},
{Point{0, 0}, Point{1, 1}, 2},
{Point{0, 0}, Point{4, 4}, 8},
{Point{0, 0}, Point{4, 6}, 10},
{Point{8, 0}, Point{8, 0}, 0},
{Point{8, 0}, Point{8, 8}, 8},
{Point{8, 0}, Point{0, 8}, 16},
{Point{X: 0, Y: 0}, Point{X: 0, Y: 0}, 0},
{Point{X: 0, Y: 0}, Point{X: 1, Y: 0}, 1},
{Point{X: 0, Y: 0}, Point{X: 0, Y: 1}, 1},
{Point{X: 0, Y: 0}, Point{X: 1, Y: 1}, 2},
{Point{X: 0, Y: 0}, Point{X: 4, Y: 4}, 8},
{Point{X: 0, Y: 0}, Point{X: 4, Y: 6}, 10},
{Point{X: 8, Y: 0}, Point{X: 8, Y: 0}, 0},
{Point{X: 8, Y: 0}, Point{X: 8, Y: 8}, 8},
{Point{X: 8, Y: 0}, Point{X: 0, Y: 8}, 16},
}
for _, test := range tests {
@ -704,20 +728,20 @@ func TestGetUnoccupiedPoints(t *testing.T) {
Height: 1,
Width: 1,
},
[]Point{{0, 0}},
[]Point{{X: 0, Y: 0}},
},
{
&BoardState{
Height: 1,
Width: 2,
},
[]Point{{0, 0}, {1, 0}},
[]Point{{X: 0, Y: 0}, {X: 1, Y: 0}},
},
{
&BoardState{
Height: 1,
Width: 1,
Food: []Point{{0, 0}, {101, 202}, {-4, -5}},
Food: []Point{{X: 0, Y: 0}, {X: 101, Y: 202}, {X: -4, Y: -5}},
},
[]Point{},
},
@ -725,15 +749,15 @@ func TestGetUnoccupiedPoints(t *testing.T) {
&BoardState{
Height: 2,
Width: 2,
Food: []Point{{0, 0}, {1, 0}},
Food: []Point{{X: 0, Y: 0}, {X: 1, Y: 0}},
},
[]Point{{0, 1}, {1, 1}},
[]Point{{X: 0, Y: 1}, {X: 1, Y: 1}},
},
{
&BoardState{
Height: 2,
Width: 2,
Food: []Point{{0, 0}, {0, 1}, {1, 0}, {1, 1}},
Food: []Point{{X: 0, Y: 0}, {X: 0, Y: 1}, {X: 1, Y: 0}, {X: 1, Y: 1}},
},
[]Point{},
},
@ -742,38 +766,38 @@ func TestGetUnoccupiedPoints(t *testing.T) {
Height: 4,
Width: 1,
Snakes: []Snake{
{Body: []Point{{0, 0}}},
{Body: []Point{{X: 0, Y: 0}}},
},
},
[]Point{{0, 1}, {0, 2}, {0, 3}},
[]Point{{X: 0, Y: 1}, {X: 0, Y: 2}, {X: 0, Y: 3}},
},
{
&BoardState{
Height: 2,
Width: 3,
Snakes: []Snake{
{Body: []Point{{0, 0}, {1, 0}, {1, 1}}},
{Body: []Point{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 1, Y: 1}}},
},
},
[]Point{{0, 1}, {2, 0}, {2, 1}},
[]Point{{X: 0, Y: 1}, {X: 2, Y: 0}, {X: 2, Y: 1}},
},
{
&BoardState{
Height: 2,
Width: 3,
Food: []Point{{0, 0}, {1, 0}, {1, 1}, {2, 0}},
Food: []Point{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 1, Y: 1}, {X: 2, Y: 0}},
Snakes: []Snake{
{Body: []Point{{0, 0}, {1, 0}, {1, 1}}},
{Body: []Point{{0, 1}}},
{Body: []Point{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 1, Y: 1}}},
{Body: []Point{{X: 0, Y: 1}}},
},
},
[]Point{{2, 1}},
[]Point{{X: 2, Y: 1}},
},
{
&BoardState{
Height: 1,
Width: 1,
Hazards: []Point{{0, 0}},
Hazards: []Point{{X: 0, Y: 0}},
},
[]Point{},
},
@ -781,22 +805,22 @@ func TestGetUnoccupiedPoints(t *testing.T) {
&BoardState{
Height: 2,
Width: 2,
Hazards: []Point{{1, 1}},
Hazards: []Point{{X: 1, Y: 1}},
},
[]Point{{0, 0}, {0, 1}, {1, 0}},
[]Point{{X: 0, Y: 0}, {X: 0, Y: 1}, {X: 1, Y: 0}},
},
{
&BoardState{
Height: 2,
Width: 3,
Food: []Point{{1, 1}, {2, 0}},
Food: []Point{{X: 1, Y: 1}, {X: 2, Y: 0}},
Snakes: []Snake{
{Body: []Point{{0, 0}, {1, 0}, {1, 1}}},
{Body: []Point{{0, 1}}},
{Body: []Point{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 1, Y: 1}}},
{Body: []Point{{X: 0, Y: 1}}},
},
Hazards: []Point{{0, 0}, {1, 0}},
Hazards: []Point{{X: 0, Y: 0}, {X: 1, Y: 0}},
},
[]Point{{2, 1}},
[]Point{{X: 2, Y: 1}},
},
}
@ -819,20 +843,20 @@ func TestGetEvenUnoccupiedPoints(t *testing.T) {
Height: 1,
Width: 1,
},
[]Point{{0, 0}},
[]Point{{X: 0, Y: 0}},
},
{
&BoardState{
Height: 2,
Width: 2,
},
[]Point{{0, 0}, {1, 1}},
[]Point{{X: 0, Y: 0}, {X: 1, Y: 1}},
},
{
&BoardState{
Height: 1,
Width: 1,
Food: []Point{{0, 0}, {101, 202}, {-4, -5}},
Food: []Point{{X: 0, Y: 0}, {X: 101, Y: 202}, {X: -4, Y: -5}},
},
[]Point{},
},
@ -840,15 +864,15 @@ func TestGetEvenUnoccupiedPoints(t *testing.T) {
&BoardState{
Height: 2,
Width: 2,
Food: []Point{{0, 0}, {1, 0}},
Food: []Point{{X: 0, Y: 0}, {X: 1, Y: 0}},
},
[]Point{{1, 1}},
[]Point{{X: 1, Y: 1}},
},
{
&BoardState{
Height: 4,
Width: 4,
Food: []Point{{0, 0}, {0, 2}, {1, 1}, {1, 3}, {2, 0}, {2, 2}, {3, 1}, {3, 3}},
Food: []Point{{X: 0, Y: 0}, {X: 0, Y: 2}, {X: 1, Y: 1}, {X: 1, Y: 3}, {X: 2, Y: 0}, {X: 2, Y: 2}, {X: 3, Y: 1}, {X: 3, Y: 3}},
},
[]Point{},
},
@ -857,32 +881,32 @@ func TestGetEvenUnoccupiedPoints(t *testing.T) {
Height: 4,
Width: 1,
Snakes: []Snake{
{Body: []Point{{0, 0}}},
{Body: []Point{{X: 0, Y: 0}}},
},
},
[]Point{{0, 2}},
[]Point{{X: 0, Y: 2}},
},
{
&BoardState{
Height: 2,
Width: 3,
Snakes: []Snake{
{Body: []Point{{0, 0}, {1, 0}, {1, 1}}},
{Body: []Point{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 1, Y: 1}}},
},
},
[]Point{{2, 0}},
[]Point{{X: 2, Y: 0}},
},
{
&BoardState{
Height: 2,
Width: 3,
Food: []Point{{0, 0}, {1, 0}, {1, 1}, {2, 1}},
Food: []Point{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 1, Y: 1}, {X: 2, Y: 1}},
Snakes: []Snake{
{Body: []Point{{0, 0}, {1, 0}, {1, 1}}},
{Body: []Point{{0, 1}}},
{Body: []Point{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 1, Y: 1}}},
{Body: []Point{{X: 0, Y: 1}}},
},
},
[]Point{{2, 0}},
[]Point{{X: 2, Y: 0}},
},
}
@ -902,7 +926,7 @@ func TestPlaceFoodRandomly(t *testing.T) {
Height: 1,
Width: 3,
Snakes: []Snake{
{Body: []Point{{1, 0}}},
{Body: []Point{{X: 1, Y: 0}}},
},
}
// Food should never spawn, no room

View file

@ -29,8 +29,8 @@ func (gc *gameTestCase) requireValidNextState(t *testing.T, r Ruleset) {
t.Helper()
t.Run(gc.name, func(t *testing.T) {
t.Helper()
prev := gc.prevState.Clone() // clone to protect against mutation (so we can ru-use test cases)
nextState, err := r.CreateNextBoardState(prev, gc.moves)
prev := gc.prevState.Clone() // clone to protect against mutation (so we can re-use test cases)
_, nextState, err := r.Execute(prev, gc.moves)
require.Equal(t, gc.expectedError, err)
if gc.expectedState != nil {
require.Equal(t, gc.expectedState.Width, nextState.Width)

View file

@ -88,7 +88,9 @@ func NewPlayCommand() *cobra.Command {
if err := gameState.Initialize(); err != nil {
log.ERROR.Fatalf("Error initializing game: %v", err)
}
gameState.Run()
if err := gameState.Run(); err != nil {
log.ERROR.Fatalf("Error running game: %v", err)
}
},
}
@ -143,7 +145,6 @@ func (gameState *GameState) Initialize() error {
// 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),
@ -155,7 +156,7 @@ func (gameState *GameState) Initialize() error {
WithSeed(gameState.Seed).
WithParams(gameState.settings).
WithSolo(len(gameState.URLs) < 2).
Ruleset()
NamedRuleset(gameState.GameType)
gameState.ruleset = ruleset
// Initialize snake states as empty until we can ping the snake URLs
@ -173,13 +174,22 @@ func (gameState *GameState) Initialize() error {
}
// Setup and run a full game.
func (gameState *GameState) Run() {
func (gameState *GameState) Run() error {
var gameOver bool
var err error
// Setup local state for snakes
gameState.snakeStates = gameState.buildSnakesFromOptions()
gameState.snakeStates, err = gameState.buildSnakesFromOptions()
if err != nil {
return fmt.Errorf("Error getting snake metadata: %w", err)
}
rand.Seed(gameState.Seed)
boardState := gameState.initializeBoardFromArgs()
gameOver, boardState, err := gameState.initializeBoardFromArgs()
if err != nil {
return fmt.Errorf("Error initializing board: %w", err)
}
gameExporter := GameExporter{
game: gameState.createClientGame(),
@ -209,7 +219,7 @@ func (gameState *GameState) Run() {
if gameState.ViewInBrowser {
serverURL, err := boardServer.Listen()
if err != nil {
log.ERROR.Fatalf("Error starting HTTP server: %v", err)
return fmt.Errorf("Error starting HTTP server: %w", err)
}
defer boardServer.Shutdown()
log.INFO.Printf("Board server listening on %s", serverURL)
@ -233,13 +243,7 @@ func (gameState *GameState) Run() {
gameState.printState(boardState)
}
var endTime time.Time
for v := false; !v; v, _ = gameState.ruleset.IsGameOver(boardState) {
if gameState.TurnDuration > 0 {
endTime = time.Now().Add(time.Duration(gameState.TurnDuration) * time.Millisecond)
}
// Export game first, if enabled, so that we save the board on turn zero
// Export game first, if enabled, so that we capture the request for turn zero.
if exportGame {
// The output file was designed in a way so that (nearly) every entry is equivalent to a valid API request.
// This is meant to help unlock further development of tools such as replaying a saved game by simply copying each line and sending it as a POST request.
@ -255,7 +259,21 @@ func (gameState *GameState) Run() {
}
}
boardState = gameState.createNextBoardState(boardState)
var endTime time.Time
for !gameOver {
if gameState.TurnDuration > 0 {
endTime = time.Now().Add(time.Duration(gameState.TurnDuration) * time.Millisecond)
}
gameOver, boardState, err = gameState.createNextBoardState(boardState)
if err != nil {
return fmt.Errorf("Error processing game: %w", err)
}
if gameOver {
// Stop processing here - because game over is detected at the start of the pipeline, nothing will have changed.
break
}
if gameState.ViewMap {
gameState.printMap(boardState)
@ -274,9 +292,7 @@ func (gameState *GameState) Run() {
if gameState.ViewInBrowser {
boardServer.SendEvent(gameState.buildFrameEvent(boardState))
}
}
// Export final turn
if exportGame {
for _, snakeState := range gameState.snakeStates {
snakeRequest := gameState.getRequestBodyForSnake(boardState, snakeState)
@ -284,6 +300,7 @@ func (gameState *GameState) Run() {
break
}
}
}
gameExporter.isDraw = false
@ -320,24 +337,26 @@ func (gameState *GameState) Run() {
if exportGame {
lines, err := gameExporter.FlushToFile(gameState.outputFile)
if err != nil {
log.ERROR.Fatalf("Unable to export game. Reason: %v", err)
return fmt.Errorf("Unable to export game: %w", err)
}
log.INFO.Printf("Wrote %d lines to output file: %s", lines, gameState.OutputPath)
}
return nil
}
func (gameState *GameState) initializeBoardFromArgs() *rules.BoardState {
func (gameState *GameState) initializeBoardFromArgs() (bool, *rules.BoardState, error) {
snakeIds := []string{}
for _, snakeState := range gameState.snakeStates {
snakeIds = append(snakeIds, snakeState.ID)
}
boardState, err := maps.SetupBoard(gameState.gameMap.ID(), gameState.ruleset.Settings(), gameState.Width, gameState.Height, snakeIds)
if err != nil {
log.ERROR.Fatalf("Error Initializing Board State: %v", err)
return false, nil, fmt.Errorf("Error initializing BoardState with map: %w", err)
}
boardState, err = gameState.ruleset.ModifyInitialBoardState(boardState)
gameOver, boardState, err := gameState.ruleset.Execute(boardState, nil)
if err != nil {
log.ERROR.Fatalf("Error Initializing Board State: %v", err)
return false, nil, fmt.Errorf("Error initializing BoardState with ruleset: %w", err)
}
for _, snakeState := range gameState.snakeStates {
@ -351,12 +370,18 @@ func (gameState *GameState) initializeBoardFromArgs() *rules.BoardState {
log.WARN.Printf("Request to %v failed", u.String())
}
}
return boardState
return gameOver, boardState, nil
}
func (gameState *GameState) createNextBoardState(boardState *rules.BoardState) *rules.BoardState {
stateUpdates := make(chan SnakeState, len(gameState.snakeStates))
func (gameState *GameState) createNextBoardState(boardState *rules.BoardState) (bool, *rules.BoardState, error) {
// apply PreUpdateBoard before making requests to snakes
boardState, err := maps.PreUpdateBoard(gameState.gameMap, boardState, gameState.ruleset.Settings())
if err != nil {
return false, boardState, fmt.Errorf("Error pre-updating board with game map: %w", err)
}
// get moves from snakes
stateUpdates := make(chan SnakeState, len(gameState.snakeStates))
if gameState.Sequential {
for _, snakeState := range gameState.snakeStates {
for _, snake := range boardState.Snakes {
@ -393,19 +418,20 @@ func (gameState *GameState) createNextBoardState(boardState *rules.BoardState) *
moves = append(moves, rules.SnakeMove{ID: snakeState.ID, Move: snakeState.LastMove})
}
boardState, err := gameState.ruleset.CreateNextBoardState(boardState, moves)
gameOver, boardState, err := gameState.ruleset.Execute(boardState, moves)
if err != nil {
log.ERROR.Fatalf("Error producing next board state: %v", err)
return false, boardState, fmt.Errorf("Error updating board state from ruleset: %w", err)
}
boardState, err = maps.UpdateBoard(gameState.gameMap.ID(), boardState, gameState.ruleset.Settings())
// apply PostUpdateBoard after ruleset operates on snake moves
boardState, err = maps.PostUpdateBoard(gameState.gameMap, boardState, gameState.ruleset.Settings())
if err != nil {
log.ERROR.Fatalf("Error updating board with game map: %v", err)
return false, boardState, fmt.Errorf("Error post-updating board with game map: %w", err)
}
boardState.Turn += 1
return boardState
return gameOver, boardState, nil
}
func (gameState *GameState) getSnakeUpdate(boardState *rules.BoardState, snakeState SnakeState) SnakeState {
@ -522,13 +548,13 @@ func (gameState *GameState) createClientGame() client.Game {
Ruleset: client.Ruleset{
Name: gameState.ruleset.Name(),
Version: "cli", // TODO: Use GitHub Release Version
Settings: gameState.ruleset.Settings(),
Settings: client.ConvertRulesetSettings(gameState.ruleset.Settings()),
},
Map: gameState.gameMap.ID(),
}
}
func (gameState *GameState) buildSnakesFromOptions() map[string]SnakeState {
func (gameState *GameState) buildSnakesFromOptions() (map[string]SnakeState, error) {
bodyChars := []rune{'■', '⌀', '●', '☻', '◘', '☺', '□', '⍟'}
var numSnakes int
snakes := map[string]SnakeState{}
@ -560,11 +586,11 @@ func (gameState *GameState) buildSnakesFromOptions() map[string]SnakeState {
if i < numURLs {
u, err := url.ParseRequestURI(gameState.URLs[i])
if err != nil {
log.ERROR.Fatalf("URL %v is not valid: %v", gameState.URLs[i], err)
return nil, fmt.Errorf("URL %v is not valid: %w", gameState.URLs[i], err)
}
snakeURL = u.String()
} else {
log.ERROR.Fatalf("URL for name %v is missing", gameState.Names[i])
return nil, fmt.Errorf("URL for name %v is missing", gameState.Names[i])
}
snakeState := SnakeState{
@ -573,25 +599,25 @@ func (gameState *GameState) buildSnakesFromOptions() map[string]SnakeState {
var snakeErr error
res, _, err := gameState.httpClient.Get(snakeURL)
if err != nil {
log.ERROR.Fatalf("Snake metadata request to %v failed: %v", snakeURL, err)
return nil, fmt.Errorf("Snake metadata request to %v failed: %w", snakeURL, err)
}
snakeState.StatusCode = res.StatusCode
if res.Body == nil {
log.ERROR.Fatalf("Empty response body from snake metadata URL: %v", snakeURL)
return nil, fmt.Errorf("Empty response body from snake metadata URL: %v", snakeURL)
}
defer res.Body.Close()
body, readErr := ioutil.ReadAll(res.Body)
if readErr != nil {
log.ERROR.Fatalf("Error reading from snake metadata URL %v: %v", snakeURL, readErr)
return nil, fmt.Errorf("Error reading from snake metadata URL %v: %w", snakeURL, readErr)
}
pingResponse := client.SnakeMetadataResponse{}
jsonErr := json.Unmarshal(body, &pingResponse)
if jsonErr != nil {
log.ERROR.Fatalf("Failed to parse response from %v: %v", snakeURL, jsonErr)
return nil, fmt.Errorf("Failed to parse response from %v: %w", snakeURL, jsonErr)
}
snakeState.Head = pingResponse.Head
@ -608,7 +634,7 @@ func (gameState *GameState) buildSnakesFromOptions() map[string]SnakeState {
log.INFO.Printf("Snake ID: %v URL: %v, Name: \"%v\"", snakeState.ID, snakeURL, snakeState.Name)
}
return snakes
return snakes, nil
}
func (gameState *GameState) printState(boardState *rules.BoardState) {
@ -762,7 +788,8 @@ func (gameState *GameState) buildFrameEvent(boardState *rules.BoardState) board.
func serialiseSnakeRequest(snakeRequest client.SnakeRequest) []byte {
requestJSON, err := json.Marshal(snakeRequest)
if err != nil {
log.ERROR.Fatalf("Error marshalling JSON from State: %v", err)
// This is likely to be a programming error like a unsupported type or cyclical reference
log.ERROR.Panicf("Error marshalling JSON from State: %v", err)
}
return requestJSON
}

View file

@ -45,11 +45,10 @@ func buildDefaultGameState() *GameState {
func TestGetIndividualBoardStateForSnake(t *testing.T) {
s1 := rules.Snake{ID: "one", Body: []rules.Point{{X: 3, Y: 3}}}
s2 := rules.Snake{ID: "two", Body: []rules.Point{{X: 4, Y: 3}}}
state := &rules.BoardState{
Height: 11,
Width: 11,
Snakes: []rules.Snake{s1, s2},
}
state := rules.NewBoardState(11, 11).
WithSnakes(
[]rules.Snake{s1, s2},
)
s1State := SnakeState{
ID: "one",
Name: "ONE",
@ -85,11 +84,8 @@ func TestGetIndividualBoardStateForSnake(t *testing.T) {
func TestSettingsRequestSerialization(t *testing.T) {
s1 := rules.Snake{ID: "one", Body: []rules.Point{{X: 3, Y: 3}}}
s2 := rules.Snake{ID: "two", Body: []rules.Point{{X: 4, Y: 3}}}
state := &rules.BoardState{
Height: 11,
Width: 11,
Snakes: []rules.Snake{s1, s2},
}
state := rules.NewBoardState(11, 11).
WithSnakes([]rules.Snake{s1, s2})
s1State := SnakeState{
ID: "one",
Name: "ONE",
@ -255,12 +251,11 @@ func TestBuildFrameEvent(t *testing.T) {
},
{
name: "snake fields",
boardState: &rules.BoardState{
Turn: 99,
Height: 19,
Width: 25,
Food: []rules.Point{{X: 9, Y: 4}},
Snakes: []rules.Snake{
boardState: rules.NewBoardState(19, 25).
WithTurn(99).
WithFood([]rules.Point{{X: 9, Y: 4}}).
WithHazards([]rules.Point{{X: 8, Y: 6}}).
WithSnakes([]rules.Snake{
{
ID: "1",
Body: []rules.Point{
@ -273,9 +268,7 @@ func TestBuildFrameEvent(t *testing.T) {
EliminatedOnTurn: 45,
EliminatedBy: "1",
},
},
Hazards: []rules.Point{{X: 8, Y: 6}},
},
}),
snakeStates: map[string]SnakeState{
"1": {
URL: "http://example.com",
@ -326,18 +319,15 @@ func TestBuildFrameEvent(t *testing.T) {
},
{
name: "snake errors",
boardState: &rules.BoardState{
Height: 19,
Width: 25,
Snakes: []rules.Snake{
boardState: rules.NewBoardState(19, 25).
WithSnakes([]rules.Snake{
{
ID: "bad_status",
},
{
ID: "connection_error",
},
},
},
}),
snakeStates: map[string]SnakeState{
"bad_status": {
StatusCode: 504,
@ -366,6 +356,8 @@ func TestBuildFrameEvent(t *testing.T) {
Error: "0:Error communicating with server",
},
},
Food: []rules.Point{},
Hazards: []rules.Point{},
},
},
},
@ -384,11 +376,7 @@ func TestBuildFrameEvent(t *testing.T) {
func TestGetMoveForSnake(t *testing.T) {
s1 := rules.Snake{ID: "one", Body: []rules.Point{{X: 3, Y: 3}}}
s2 := rules.Snake{ID: "two", Body: []rules.Point{{X: 4, Y: 3}}}
boardState := &rules.BoardState{
Height: 11,
Width: 11,
Snakes: []rules.Snake{s1, s2},
}
boardState := rules.NewBoardState(11, 11).WithSnakes([]rules.Snake{s1, s2})
tests := []struct {
name string
@ -530,11 +518,7 @@ func TestGetMoveForSnake(t *testing.T) {
func TestCreateNextBoardState(t *testing.T) {
s1 := rules.Snake{ID: "one", Body: []rules.Point{{X: 3, Y: 3}}}
boardState := &rules.BoardState{
Height: 11,
Width: 11,
Snakes: []rules.Snake{s1},
}
boardState := rules.NewBoardState(11, 11).WithSnakes([]rules.Snake{s1})
snakeState := SnakeState{
ID: s1.ID,
URL: "http://example.com",
@ -549,7 +533,9 @@ func TestCreateNextBoardState(t *testing.T) {
gameState.snakeStates = map[string]SnakeState{s1.ID: snakeState}
gameState.httpClient = stubHTTPClient{nil, 200, func(_ string) string { return `{"move": "right"}` }, 54 * time.Millisecond}
nextBoardState := gameState.createNextBoardState(boardState)
gameOver, nextBoardState, err := gameState.createNextBoardState(boardState)
require.NoError(t, err)
require.False(t, gameOver)
snakeState = gameState.snakeStates[s1.ID]
require.NotNil(t, nextBoardState)
@ -593,16 +579,18 @@ func TestOutputFile(t *testing.T) {
outputFile := new(closableBuffer)
gameState.outputFile = outputFile
gameState.ruleset = StubRuleset{maxTurns: 1, settings: rules.Settings{
FoodSpawnChance: 1,
MinimumFood: 2,
HazardDamagePerTurn: 3,
RoyaleSettings: rules.RoyaleSettings{
ShrinkEveryNTurns: 4,
},
}}
gameState.ruleset = StubRuleset{
maxTurns: 1,
settings: rules.NewSettings(map[string]string{
rules.ParamFoodSpawnChance: "1",
rules.ParamMinimumFood: "2",
rules.ParamHazardDamagePerTurn: "3",
rules.ParamShrinkEveryNTurns: "4",
}),
}
gameState.Run()
err = gameState.Run()
require.NoError(t, err)
lines := strings.Split(outputFile.String(), "\n")
require.Len(t, lines, 5)
@ -626,14 +614,8 @@ type StubRuleset struct {
func (ruleset StubRuleset) Name() string { return "standard" }
func (ruleset StubRuleset) Settings() rules.Settings { return ruleset.settings }
func (ruleset StubRuleset) ModifyInitialBoardState(initialState *rules.BoardState) (*rules.BoardState, error) {
return initialState, nil
}
func (ruleset StubRuleset) CreateNextBoardState(prevState *rules.BoardState, moves []rules.SnakeMove) (*rules.BoardState, error) {
return prevState, nil
}
func (ruleset StubRuleset) IsGameOver(state *rules.BoardState) (bool, error) {
return state.Turn >= ruleset.maxTurns, nil
func (ruleset StubRuleset) Execute(prevState *rules.BoardState, moves []rules.SnakeMove) (bool, *rules.BoardState, error) {
return prevState.Turn >= ruleset.maxTurns, prevState, nil
}
type stubHTTPClient struct {

View file

@ -9,7 +9,7 @@ func exampleSnakeRequest() SnakeRequest {
Ruleset: Ruleset{
Name: "test-ruleset-name",
Version: "cli",
Settings: exampleRulesetSettings,
Settings: ConvertRulesetSettings(exampleRulesetSettings),
},
Timeout: 33,
Source: "league",
@ -75,21 +75,9 @@ func exampleSnakeRequest() SnakeRequest {
}
}
var exampleRulesetSettings = rules.Settings{
FoodSpawnChance: 10,
MinimumFood: 20,
HazardDamagePerTurn: 30,
HazardMap: "hz_spiral",
HazardMapAuthor: "altersaddle",
RoyaleSettings: rules.RoyaleSettings{
ShrinkEveryNTurns: 40,
},
SquadSettings: rules.SquadSettings{
AllowBodyCollisions: true,
SharedElimination: true,
SharedHealth: true,
SharedLength: true,
},
}
var exampleRulesetSettings = rules.NewSettings(map[string]string{
rules.ParamFoodSpawnChance: "10",
rules.ParamMinimumFood: "20",
rules.ParamHazardDamagePerTurn: "30",
rules.ParamShrinkEveryNTurns: "40",
})

View file

@ -51,17 +51,45 @@ type Customizations struct {
type Ruleset struct {
Name string `json:"name"`
Version string `json:"version"`
Settings rules.Settings `json:"settings"`
Settings RulesetSettings `json:"settings"`
}
// RulesetSettings is deprecated: use rules.Settings instead
type RulesetSettings rules.Settings
// RulesetSettings contains a static collection of a few settings that are exposed through the API.
type RulesetSettings struct {
FoodSpawnChance int `json:"foodSpawnChance"`
MinimumFood int `json:"minimumFood"`
HazardDamagePerTurn int `json:"hazardDamagePerTurn"`
HazardMap string `json:"hazardMap"` // Deprecated, replaced by Game.Map
HazardMapAuthor string `json:"hazardMapAuthor"` // Deprecated, no planned replacement
RoyaleSettings RoyaleSettings `json:"royale"`
SquadSettings SquadSettings `json:"squad"` // Deprecated, provided with default fields for API compatibility
}
// RoyaleSettings is deprecated: use rules.RoyaleSettings instead
type RoyaleSettings rules.RoyaleSettings
// RoyaleSettings contains settings that are specific to the "royale" game mode
type RoyaleSettings struct {
ShrinkEveryNTurns int `json:"shrinkEveryNTurns"`
}
// SquadSettings is deprecated: use rules.SquadSettings instead
type SquadSettings rules.SquadSettings
// SquadSettings contains settings that are specific to the "squad" game mode
type SquadSettings struct {
AllowBodyCollisions bool `json:"allowBodyCollisions"`
SharedElimination bool `json:"sharedElimination"`
SharedHealth bool `json:"sharedHealth"`
SharedLength bool `json:"sharedLength"`
}
// Converts a rules.Settings (which can contain arbitrary settings) into the static RulesetSettings used in the client API.
func ConvertRulesetSettings(settings rules.Settings) RulesetSettings {
return RulesetSettings{
FoodSpawnChance: settings.Int(rules.ParamFoodSpawnChance, 0),
MinimumFood: settings.Int(rules.ParamMinimumFood, 0),
HazardDamagePerTurn: settings.Int(rules.ParamHazardDamagePerTurn, 0),
RoyaleSettings: RoyaleSettings{
ShrinkEveryNTurns: settings.Int(rules.ParamShrinkEveryNTurns, 0),
},
SquadSettings: SquadSettings{},
}
}
// Coord represents a point on the board
type Coord struct {

View file

@ -4,7 +4,6 @@ import (
"encoding/json"
"testing"
"github.com/BattlesnakeOfficial/rules"
"github.com/BattlesnakeOfficial/rules/test"
"github.com/stretchr/testify/require"
)
@ -19,7 +18,7 @@ func TestBuildSnakeRequestJSON(t *testing.T) {
func TestBuildSnakeRequestJSONEmptyRulesetSettings(t *testing.T) {
snakeRequest := exampleSnakeRequest()
snakeRequest.Game.Ruleset.Settings = rules.Settings{}
snakeRequest.Game.Ruleset.Settings = RulesetSettings{}
data, err := json.MarshalIndent(snakeRequest, "", " ")
require.NoError(t, err)

View file

@ -8,16 +8,16 @@
"foodSpawnChance": 10,
"minimumFood": 20,
"hazardDamagePerTurn": 30,
"hazardMap": "hz_spiral",
"hazardMapAuthor": "altersaddle",
"hazardMap": "",
"hazardMapAuthor": "",
"royale": {
"shrinkEveryNTurns": 40
},
"squad": {
"allowBodyCollisions": true,
"sharedElimination": true,
"sharedHealth": true,
"sharedLength": true
"allowBodyCollisions": false,
"sharedElimination": false,
"sharedHealth": false,
"sharedLength": false
}
}
},

View file

@ -22,31 +22,6 @@ var wrappedConstrictorRulesetStages = []string{
StageModifySnakesAlwaysGrow,
}
type ConstrictorRuleset struct {
StandardRuleset
}
func (r *ConstrictorRuleset) Name() string { return GameTypeConstrictor }
func (r ConstrictorRuleset) Execute(bs *BoardState, s Settings, sm []SnakeMove) (bool, *BoardState, error) {
return NewPipeline(constrictorRulesetStages...).Execute(bs, s, sm)
}
func (r *ConstrictorRuleset) ModifyInitialBoardState(initialBoardState *BoardState) (*BoardState, error) {
_, nextState, err := r.Execute(initialBoardState, r.Settings(), nil)
return nextState, err
}
func (r *ConstrictorRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) {
_, nextState, err := r.Execute(prevState, r.Settings(), moves)
return nextState, err
}
func (r *ConstrictorRuleset) IsGameOver(b *BoardState) (bool, error) {
return GameOverStandard(b, r.Settings(), nil)
}
func RemoveFoodConstrictor(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
// Remove all food from the board
b.Food = []Point{}

View file

@ -4,10 +4,6 @@ import (
"testing"
)
func TestConstrictorRulesetInterface(t *testing.T) {
var _ Ruleset = (*ConstrictorRuleset)(nil)
}
// Test that two equal snakes collide and both get eliminated
// also checks:
// - food removed
@ -21,16 +17,16 @@ var constrictorMoveAndCollideMAD = gameTestCase{
Snakes: []Snake{
{
ID: "one",
Body: []Point{{1, 1}, {2, 1}},
Body: []Point{{X: 1, Y: 1}, {X: 2, Y: 1}},
Health: 99,
},
{
ID: "two",
Body: []Point{{1, 2}, {2, 2}},
Body: []Point{{X: 1, Y: 2}, {X: 2, Y: 2}},
Health: 99,
},
},
Food: []Point{{10, 10}, {9, 9}, {8, 8}},
Food: []Point{{X: 10, Y: 10}, {X: 9, Y: 9}, {X: 8, Y: 8}},
Hazards: []Point{},
},
[]SnakeMove{
@ -44,7 +40,7 @@ var constrictorMoveAndCollideMAD = gameTestCase{
Snakes: []Snake{
{
ID: "one",
Body: []Point{{1, 2}, {1, 1}, {1, 1}},
Body: []Point{{X: 1, Y: 2}, {X: 1, Y: 1}, {X: 1, Y: 1}},
Health: 100,
EliminatedCause: EliminatedByCollision,
EliminatedBy: "two",
@ -52,7 +48,7 @@ var constrictorMoveAndCollideMAD = gameTestCase{
},
{
ID: "two",
Body: []Point{{1, 1}, {1, 2}, {1, 2}},
Body: []Point{{X: 1, Y: 1}, {X: 1, Y: 2}, {X: 1, Y: 2}},
Health: 100,
EliminatedCause: EliminatedByCollision,
EliminatedBy: "one",
@ -70,15 +66,11 @@ func TestConstrictorCreateNextBoardState(t *testing.T) {
standardCaseErrZeroLengthSnake,
constrictorMoveAndCollideMAD,
}
rb := NewRulesetBuilder().WithParams(map[string]string{
ParamGameType: GameTypeConstrictor,
})
r := ConstrictorRuleset{}
r := NewRulesetBuilder().NamedRuleset(GameTypeConstrictor)
for _, gc := range cases {
gc.requireValidNextState(t, &r)
// also test a RulesBuilder constructed instance
gc.requireValidNextState(t, rb.Ruleset())
// test a RulesBuilder constructed instance
gc.requireValidNextState(t, r)
// also test a pipeline with the same settings
gc.requireValidNextState(t, rb.PipelineRuleset(GameTypeConstrictor, NewPipeline(constrictorRulesetStages...)))
gc.requireValidNextState(t, NewRulesetBuilder().PipelineRuleset(GameTypeConstrictor, NewPipeline(constrictorRulesetStages...)))
}
}

View file

@ -63,7 +63,7 @@ func (m ArcadeMazeMap) SetupBoard(initialBoardState *rules.BoardState, settings
editor.AddHazard(hazard)
}
if settings.MinimumFood > 0 {
if settings.Int(rules.ParamMinimumFood, 0) > 0 {
// Add food in center
editor.AddFood(rules.Point{X: 9, Y: 11})
}
@ -71,11 +71,16 @@ func (m ArcadeMazeMap) SetupBoard(initialBoardState *rules.BoardState, settings
return nil
}
func (m ArcadeMazeMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
func (m ArcadeMazeMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}
func (m ArcadeMazeMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
rand := settings.GetRand(lastBoardState.Turn)
// Respect FoodSpawnChance setting
if settings.FoodSpawnChance == 0 || rand.Intn(100) > settings.FoodSpawnChance {
foodSpawnChance := settings.Int(rules.ParamFoodSpawnChance, 0)
if foodSpawnChance == 0 || rand.Intn(100) > foodSpawnChance {
return nil
}

View file

@ -136,7 +136,11 @@ func (m CastleWallMediumHazardsMap) SetupBoard(initialBoardState *rules.BoardSta
return setupCastleWallBoard(m.Meta().MaxPlayers, startPositions, castleWallMediumHazards, initialBoardState, settings, editor)
}
func (m CastleWallMediumHazardsMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
func (m CastleWallMediumHazardsMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}
func (m CastleWallMediumHazardsMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
maxFood := 2
return updateCastleWallBoard(maxFood, castleWallMediumFood, lastBoardState, settings, editor)
}
@ -228,7 +232,11 @@ func (m CastleWallLargeHazardsMap) SetupBoard(initialBoardState *rules.BoardStat
return setupCastleWallBoard(m.Meta().MaxPlayers, startPositions, castleWallLargeHazards, initialBoardState, settings, editor)
}
func (m CastleWallLargeHazardsMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
func (m CastleWallLargeHazardsMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}
func (m CastleWallLargeHazardsMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
maxFood := 2
return updateCastleWallBoard(maxFood, castleWallLargeFood, lastBoardState, settings, editor)
}
@ -420,7 +428,11 @@ func (m CastleWallExtraLargeHazardsMap) SetupBoard(initialBoardState *rules.Boar
return setupCastleWallBoard(m.Meta().MaxPlayers, startPositions, castleWallExtraLargeHazards, initialBoardState, settings, editor)
}
func (m CastleWallExtraLargeHazardsMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
func (m CastleWallExtraLargeHazardsMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}
func (m CastleWallExtraLargeHazardsMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
maxFood := 4
return updateCastleWallBoard(maxFood, castleWallExtraLargeFood, lastBoardState, settings, editor)
}

View file

@ -53,6 +53,10 @@ func (m EmptyMap) SetupBoard(initialBoardState *rules.BoardState, settings rules
return nil
}
func (m EmptyMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
func (m EmptyMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}
func (m EmptyMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}

View file

@ -28,55 +28,28 @@ func TestEmptyMapSetupBoard(t *testing.T) {
"empty 7x7",
rules.NewBoardState(7, 7),
rules.MinRand,
&rules.BoardState{
Width: 7,
Height: 7,
Snakes: []rules.Snake{},
Food: []rules.Point{},
Hazards: []rules.Point{},
},
rules.NewBoardState(7, 7),
nil,
},
{
"not enough room for snakes 7x7",
&rules.BoardState{
Width: 7,
Height: 7,
Snakes: generateSnakes(17),
Food: []rules.Point{},
Hazards: []rules.Point{},
},
rules.NewBoardState(7, 7).WithSnakes(generateSnakes(17)),
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.NewBoardState(5, 5).WithSnakes(generateSnakes(14)),
rules.MinRand,
nil,
rules.ErrorTooManySnakes,
},
{
"full 11x11 min",
&rules.BoardState{
Width: 11,
Height: 11,
Snakes: generateSnakes(8),
Food: []rules.Point{},
Hazards: []rules.Point{},
},
rules.NewBoardState(11, 11).WithSnakes(generateSnakes(8)),
rules.MinRand,
&rules.BoardState{
Width: 11,
Height: 11,
Snakes: []rules.Snake{
rules.NewBoardState(11, 11).WithSnakes([]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},
@ -85,26 +58,14 @@ func TestEmptyMapSetupBoard(t *testing.T) {
{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.NewBoardState(11, 11).WithSnakes(generateSnakes(8)),
rules.MaxRand,
&rules.BoardState{
Width: 11,
Height: 11,
Snakes: []rules.Snake{
rules.NewBoardState(11, 11).WithSnakes([]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},
@ -113,10 +74,7 @@ func TestEmptyMapSetupBoard(t *testing.T) {
{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,
},
}
@ -139,27 +97,13 @@ func TestEmptyMapSetupBoard(t *testing.T) {
func TestEmptyMapUpdateBoard(t *testing.T) {
m := maps.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)
initialBoardState := rules.NewBoardState(2, 2).WithFood([]rules.Point{{X: 0, Y: 0}})
settings := rules.NewSettingsWithParams(rules.ParamFoodSpawnChance, "50", rules.ParamMinimumFood, "2").WithRand(rules.MaxRand)
nextBoardState := initialBoardState.Clone()
err := m.UpdateBoard(initialBoardState.Clone(), settings, maps.NewBoardStateEditor(nextBoardState))
err := m.PostUpdateBoard(initialBoardState.Clone(), settings, maps.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)
expectedBoardState := rules.NewBoardState(2, 2).WithFood([]rules.Point{{X: 0, Y: 0}})
require.Equal(t, expectedBoardState, nextBoardState)
}

View file

@ -24,8 +24,21 @@ type GameMap interface {
// 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
// Called every turn to optionally update the board before the board is sent to snakes to get their moves.
// Changes made here will be seen by snakes before before making their moves, but users in the
// browser will see the changes at the same time as the snakes' moves.
//
// State that is stored in the map by this method will be visible to the PostUpdateBoard method
// later in the same turn, but will not nessecarily be available when processing later turns.
//
// Disclaimer: Unless you have a specific usecase like moving hazards or storing intermediate state,
// PostUpdateBoard is probably the better function to use.
PreUpdateBoard(previousBoardState *rules.BoardState, settings rules.Settings, editor Editor) error
// Called every turn to optionally update the board after all other rules have been applied.
// Changes made here will be seen by both snakes and users in the browser, before before snakes
// make their next moves.
PostUpdateBoard(previousBoardState *rules.BoardState, settings rules.Settings, editor Editor) error
}
type Metadata struct {
@ -166,6 +179,12 @@ type Editor interface {
// Note: the body values in the return value are a copy and modifying them won't affect the board.
SnakeBodies() map[string][]rules.Point
// Get an editable reference to the BoardState's GameState field
GameState() map[string]string
// Get an editable reference to the BoardState's PointState field
PointState() map[rules.Point]int
// Given a list of Snakes and a list of head coordinates, randomly place
// the snakes on those coordinates, or return an error if placement of all
// Snakes is impossible.
@ -270,6 +289,16 @@ func (editor *BoardStateEditor) SnakeBodies() map[string][]rules.Point {
return result
}
// Get an editable reference to the BoardState's GameState field
func (editor *BoardStateEditor) GameState() map[string]string {
return editor.boardState.GameState
}
// Get an editable reference to the BoardState's PointState field
func (editor *BoardStateEditor) PointState() map[rules.Point]int {
return editor.boardState.PointState
}
// Given a list of Snakes and a list of head coordinates, randomly place
// the snakes on those coordinates, or return an error if placement of all
// Snakes is impossible.

View file

@ -135,18 +135,16 @@ func TestBoardStateEditor(t *testing.T) {
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{
expected := rules.NewBoardState(11, 11).
WithFood([]rules.Point{
{X: 1, Y: 3},
{X: 3, Y: 7},
},
Hazards: []rules.Point{
}).
WithHazards([]rules.Point{
{X: 1, Y: 3},
{X: 3, Y: 7},
},
Snakes: []rules.Snake{
}).
WithSnakes([]rules.Snake{
{
ID: "existing_snake",
Health: 99,
@ -157,8 +155,8 @@ func TestBoardStateEditor(t *testing.T) {
Health: 98,
Body: []rules.Point{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 1, Y: 1}},
},
},
}, boardState)
})
require.Equal(t, expected, boardState)
require.Equal(t, []rules.Point{
{X: 1, Y: 3},

View file

@ -97,8 +97,12 @@ func (m HazardPitsMap) SetupBoard(initialBoardState *rules.BoardState, settings
return nil
}
func (m HazardPitsMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
err := StandardMap{}.UpdateBoard(lastBoardState, settings, editor)
func (m HazardPitsMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}
func (m HazardPitsMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
err := StandardMap{}.PostUpdateBoard(lastBoardState, settings, editor)
if err != nil {
return err
}
@ -109,9 +113,10 @@ func (m HazardPitsMap) UpdateBoard(lastBoardState *rules.BoardState, settings ru
// Cycle 3 - 3 layers
// Cycle 4-6 - 4 layers of hazards
if lastBoardState.Turn%settings.RoyaleSettings.ShrinkEveryNTurns == 0 {
shrinkEveryNTurns := settings.Int(rules.ParamShrinkEveryNTurns, 0)
if lastBoardState.Turn%shrinkEveryNTurns == 0 {
// Is it time to update the hazards
layers := (lastBoardState.Turn / settings.RoyaleSettings.ShrinkEveryNTurns) % 7
layers := (lastBoardState.Turn / shrinkEveryNTurns) % 7
if layers > 4 {
layers = 4
}

View file

@ -38,7 +38,7 @@ func TestHazardPitsMap(t *testing.T) {
state = rules.NewBoardState(int(11), int(11))
m = maps.HazardPitsMap{}
settings.RoyaleSettings.ShrinkEveryNTurns = 1
settings = rules.NewSettingsWithParams(rules.ParamShrinkEveryNTurns, "1")
editor = maps.NewBoardStateEditor(state)
require.Empty(t, state.Hazards)
err = m.SetupBoard(state, settings, editor)
@ -47,7 +47,7 @@ func TestHazardPitsMap(t *testing.T) {
// Verify the hazard progression through the turns
for i := 0; i < 16; i++ {
state.Turn = i
err = m.UpdateBoard(state, settings, editor)
err = m.PostUpdateBoard(state, settings, editor)
require.NoError(t, err)
if i == 1 {
require.Len(t, state.Hazards, 21)

View file

@ -54,8 +54,12 @@ func (m InnerBorderHazardsMap) SetupBoard(lastBoardState *rules.BoardState, sett
return nil
}
func (m InnerBorderHazardsMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return StandardMap{}.UpdateBoard(lastBoardState, settings, editor)
func (m InnerBorderHazardsMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}
func (m InnerBorderHazardsMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return StandardMap{}.PostUpdateBoard(lastBoardState, settings, editor)
}
type ConcentricRingsHazardsMap struct{}
@ -96,8 +100,12 @@ func (m ConcentricRingsHazardsMap) SetupBoard(lastBoardState *rules.BoardState,
return nil
}
func (m ConcentricRingsHazardsMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return StandardMap{}.UpdateBoard(lastBoardState, settings, editor)
func (m ConcentricRingsHazardsMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}
func (m ConcentricRingsHazardsMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return StandardMap{}.PostUpdateBoard(lastBoardState, settings, editor)
}
type ColumnsHazardsMap struct{}
@ -135,8 +143,12 @@ func (m ColumnsHazardsMap) SetupBoard(lastBoardState *rules.BoardState, settings
return nil
}
func (m ColumnsHazardsMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return StandardMap{}.UpdateBoard(lastBoardState, settings, editor)
func (m ColumnsHazardsMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}
func (m ColumnsHazardsMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return StandardMap{}.PostUpdateBoard(lastBoardState, settings, editor)
}
type SpiralHazardsMap struct{}
@ -163,8 +175,12 @@ func (m SpiralHazardsMap) SetupBoard(lastBoardState *rules.BoardState, settings
return (StandardMap{}).SetupBoard(lastBoardState, settings, editor)
}
func (m SpiralHazardsMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
err := StandardMap{}.UpdateBoard(lastBoardState, settings, editor)
func (m SpiralHazardsMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}
func (m SpiralHazardsMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
err := StandardMap{}.PostUpdateBoard(lastBoardState, settings, editor)
if err != nil {
return err
}
@ -256,8 +272,12 @@ func (m ScatterFillMap) SetupBoard(lastBoardState *rules.BoardState, settings ru
return (StandardMap{}).SetupBoard(lastBoardState, settings, editor)
}
func (m ScatterFillMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
err := StandardMap{}.UpdateBoard(lastBoardState, settings, editor)
func (m ScatterFillMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}
func (m ScatterFillMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
err := StandardMap{}.PostUpdateBoard(lastBoardState, settings, editor)
if err != nil {
return err
}
@ -308,8 +328,12 @@ func (m DirectionalExpandingBoxMap) SetupBoard(lastBoardState *rules.BoardState,
return (StandardMap{}).SetupBoard(lastBoardState, settings, editor)
}
func (m DirectionalExpandingBoxMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
err := StandardMap{}.UpdateBoard(lastBoardState, settings, editor)
func (m DirectionalExpandingBoxMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}
func (m DirectionalExpandingBoxMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
err := StandardMap{}.PostUpdateBoard(lastBoardState, settings, editor)
if err != nil {
return err
}
@ -423,8 +447,12 @@ func (m ExpandingBoxMap) SetupBoard(lastBoardState *rules.BoardState, settings r
return (StandardMap{}).SetupBoard(lastBoardState, settings, editor)
}
func (m ExpandingBoxMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
err := StandardMap{}.UpdateBoard(lastBoardState, settings, editor)
func (m ExpandingBoxMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}
func (m ExpandingBoxMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
err := StandardMap{}.PostUpdateBoard(lastBoardState, settings, editor)
if err != nil {
return err
}
@ -499,8 +527,12 @@ func (m ExpandingScatterMap) SetupBoard(lastBoardState *rules.BoardState, settin
return (StandardMap{}).SetupBoard(lastBoardState, settings, editor)
}
func (m ExpandingScatterMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
err := StandardMap{}.UpdateBoard(lastBoardState, settings, editor)
func (m ExpandingScatterMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return (StandardMap{}).PreUpdateBoard(lastBoardState, settings, editor)
}
func (m ExpandingScatterMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
err := StandardMap{}.PostUpdateBoard(lastBoardState, settings, editor)
if err != nil {
return err
}

View file

@ -102,7 +102,7 @@ func TestSpiralHazardsMap(t *testing.T) {
for i := 0; i < 1000; i++ {
state.Turn = i
err = m.UpdateBoard(state, settings, editor)
err = m.PostUpdateBoard(state, settings, editor)
require.NoError(t, err)
}
require.NotEmpty(t, state.Hazards)
@ -123,7 +123,7 @@ func TestScatterFillMap(t *testing.T) {
totalTurns := 11 * 11 * 2
for i := 0; i < totalTurns; i++ {
state.Turn = i
err = m.UpdateBoard(state, settings, editor)
err = m.PostUpdateBoard(state, settings, editor)
require.NoError(t, err)
}
require.NotEmpty(t, state.Hazards)
@ -144,7 +144,7 @@ func TestDirectionalExpandingBoxMap(t *testing.T) {
totalTurns := 1000
for i := 0; i < totalTurns; i++ {
state.Turn = i
err = m.UpdateBoard(state, settings, editor)
err = m.PostUpdateBoard(state, settings, editor)
require.NoError(t, err)
}
require.NotEmpty(t, state.Hazards)
@ -165,7 +165,7 @@ func TestExpandingBoxMap(t *testing.T) {
totalTurns := 1000
for i := 0; i < totalTurns; i++ {
state.Turn = i
err = m.UpdateBoard(state, settings, editor)
err = m.PostUpdateBoard(state, settings, editor)
require.NoError(t, err)
}
require.NotEmpty(t, state.Hazards)
@ -186,7 +186,7 @@ func TestExpandingScatterMap(t *testing.T) {
totalTurns := 1000
for i := 0; i < totalTurns; i++ {
state.Turn = i
err = m.UpdateBoard(state, settings, editor)
err = m.PostUpdateBoard(state, settings, editor)
require.NoError(t, err)
}
require.NotEmpty(t, state.Hazards)

View file

@ -50,12 +50,17 @@ func (m HealingPoolsMap) SetupBoard(initialBoardState *rules.BoardState, setting
return nil
}
func (m HealingPoolsMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
if err := (StandardMap{}).UpdateBoard(lastBoardState, settings, editor); err != nil {
func (m HealingPoolsMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}
func (m HealingPoolsMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
if err := (StandardMap{}).PostUpdateBoard(lastBoardState, settings, editor); err != nil {
return err
}
if lastBoardState.Turn > 0 && settings.RoyaleSettings.ShrinkEveryNTurns > 0 && len(lastBoardState.Hazards) > 0 && lastBoardState.Turn%settings.RoyaleSettings.ShrinkEveryNTurns == 0 {
shrinkEveryNTurns := settings.Int(rules.ParamShrinkEveryNTurns, 0)
if lastBoardState.Turn > 0 && shrinkEveryNTurns > 0 && len(lastBoardState.Hazards) > 0 && lastBoardState.Turn%shrinkEveryNTurns == 0 {
// Attempt to remove a healing pool every ShrinkEveryNTurns until there are none remaining
i := rand.Intn(len(lastBoardState.Hazards))
editor.RemoveHazard(lastBoardState.Hazards[i])

View file

@ -40,8 +40,8 @@ func TestHealingPoolsMap(t *testing.T) {
t.Run(fmt.Sprintf("%dx%d", tc.boardSize, tc.boardSize), func(t *testing.T) {
m := maps.HealingPoolsMap{}
state := rules.NewBoardState(tc.boardSize, tc.boardSize)
settings := rules.Settings{}
settings.RoyaleSettings.ShrinkEveryNTurns = 10
shrinkEveryNTurns := 10
settings := rules.NewSettingsWithParams(rules.ParamShrinkEveryNTurns, fmt.Sprint(shrinkEveryNTurns))
// ensure the hazards are added to the board at setup
editor := maps.NewBoardStateEditor(state)
@ -56,10 +56,10 @@ func TestHealingPoolsMap(t *testing.T) {
}
// ensure the hazards are removed
totalTurns := settings.RoyaleSettings.ShrinkEveryNTurns*tc.expectedHazards + 1
totalTurns := shrinkEveryNTurns*tc.expectedHazards + 1
for i := 0; i < totalTurns; i++ {
state.Turn = i
err = m.UpdateBoard(state, settings, editor)
err = m.PostUpdateBoard(state, settings, editor)
require.NoError(t, err)
}

View file

@ -25,17 +25,24 @@ func SetupBoard(mapID string, settings rules.Settings, width, height int, snakeI
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)
// PreUpdateBoard updates a board state with a map.
func PreUpdateBoard(gameMap GameMap, previousBoardState *rules.BoardState, settings rules.Settings) (*rules.BoardState, error) {
nextBoardState := previousBoardState.Clone()
editor := NewBoardStateEditor(nextBoardState)
err := gameMap.PreUpdateBoard(previousBoardState, settings, editor)
if err != nil {
return nil, err
}
return nextBoardState, nil
}
func PostUpdateBoard(gameMap GameMap, previousBoardState *rules.BoardState, settings rules.Settings) (*rules.BoardState, error) {
nextBoardState := previousBoardState.Clone()
editor := NewBoardStateEditor(nextBoardState)
err = gameMap.UpdateBoard(previousBoardState, settings, editor)
err := gameMap.PostUpdateBoard(previousBoardState, settings, editor)
if err != nil {
return nil, err
}
@ -77,7 +84,11 @@ func (m StubMap) SetupBoard(initialBoardState *rules.BoardState, settings rules.
return nil
}
func (m StubMap) UpdateBoard(previousBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
func (m StubMap) PreUpdateBoard(previousBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}
func (m StubMap) PostUpdateBoard(previousBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
if m.Error != nil {
return m.Error
}

View file

@ -82,11 +82,10 @@ func TestUpdateBoard(t *testing.T) {
},
}
previousBoardState := &rules.BoardState{
Turn: 0,
Food: []rules.Point{{X: 0, Y: 1}},
Hazards: []rules.Point{{X: 3, Y: 4}},
Snakes: []rules.Snake{
previousBoardState := rules.NewBoardState(5, 5).
WithFood([]rules.Point{{X: 0, Y: 1}}).
WithHazards([]rules.Point{{X: 3, Y: 4}}).
WithSnakes([]rules.Snake{
{
ID: "1",
Health: 100,
@ -96,11 +95,9 @@ func TestUpdateBoard(t *testing.T) {
{X: 6, Y: 2},
},
},
},
}
})
maps.TestMap(testMap.ID(), testMap, func() {
boardState, err := maps.UpdateBoard(testMap.ID(), previousBoardState, rules.Settings{})
boardState, err := maps.PostUpdateBoard(testMap, previousBoardState, rules.Settings{})
require.NoError(t, err)

View file

@ -10,14 +10,12 @@ import (
const maxBoardWidth, maxBoardHeight = 25, 25
var testSettings rules.Settings = rules.Settings{
FoodSpawnChance: 25,
MinimumFood: 1,
HazardDamagePerTurn: 14,
RoyaleSettings: rules.RoyaleSettings{
ShrinkEveryNTurns: 1,
},
}
var testSettings rules.Settings = rules.NewSettings(map[string]string{
rules.ParamFoodSpawnChance: "25",
rules.ParamMinimumFood: "1",
rules.ParamHazardDamagePerTurn: "14",
rules.ParamShrinkEveryNTurns: "1",
})
func TestRegisteredMaps(t *testing.T) {
for mapName, gameMap := range globalRegistry {
@ -96,7 +94,7 @@ func TestRegisteredMaps(t *testing.T) {
passedBoardState := previousBoardState.Clone()
tempBoardState := previousBoardState.Clone()
err := gameMap.UpdateBoard(passedBoardState, testSettings, NewBoardStateEditor(tempBoardState))
err := gameMap.PostUpdateBoard(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

@ -71,7 +71,11 @@ func (m RiverAndBridgesMediumHazardsMap) SetupBoard(initialBoardState *rules.Boa
return setupRiverAndBridgesBoard(riversAndBridgesMediumStartPositions, riversAndBridgesMediumHazards, initialBoardState, settings, editor)
}
func (m RiverAndBridgesMediumHazardsMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
func (m RiverAndBridgesMediumHazardsMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}
func (m RiverAndBridgesMediumHazardsMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return placeRiverAndBridgesFood(lastBoardState, settings, editor)
}
@ -142,7 +146,11 @@ func (m RiverAndBridgesLargeHazardsMap) SetupBoard(initialBoardState *rules.Boar
return setupRiverAndBridgesBoard(riversAndBridgesLargeStartPositions, riversAndBridgesLargeHazards, initialBoardState, settings, editor)
}
func (m RiverAndBridgesLargeHazardsMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
func (m RiverAndBridgesLargeHazardsMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}
func (m RiverAndBridgesLargeHazardsMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return placeRiverAndBridgesFood(lastBoardState, settings, editor)
}
@ -241,7 +249,11 @@ func (m RiverAndBridgesExtraLargeHazardsMap) SetupBoard(initialBoardState *rules
return setupRiverAndBridgesBoard(riversAndBridgesExtraLargeStartPositions, riversAndBridgesExtraLargeHazards, initialBoardState, settings, editor)
}
func (m RiverAndBridgesExtraLargeHazardsMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
func (m RiverAndBridgesExtraLargeHazardsMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}
func (m RiverAndBridgesExtraLargeHazardsMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return placeRiverAndBridgesFood(lastBoardState, settings, editor)
}
@ -355,7 +367,11 @@ func (m IslandsAndBridgesMediumHazardsMap) SetupBoard(initialBoardState *rules.B
return setupRiverAndBridgesBoard(islandsAndBridgesMediumStartPositions, islandsAndBridgesMediumHazards, initialBoardState, settings, editor)
}
func (m IslandsAndBridgesMediumHazardsMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
func (m IslandsAndBridgesMediumHazardsMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}
func (m IslandsAndBridgesMediumHazardsMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return placeRiverAndBridgesFood(lastBoardState, settings, editor)
}
@ -441,7 +457,11 @@ func (m IslandsAndBridgesLargeHazardsMap) SetupBoard(initialBoardState *rules.Bo
return setupRiverAndBridgesBoard(islandsAndBridgesLargeStartPositions, islandsAndBridgesLargeHazards, initialBoardState, settings, editor)
}
func (m IslandsAndBridgesLargeHazardsMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
func (m IslandsAndBridgesLargeHazardsMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}
func (m IslandsAndBridgesLargeHazardsMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return placeRiverAndBridgesFood(lastBoardState, settings, editor)
}

View file

@ -33,20 +33,25 @@ func (m RoyaleHazardsMap) SetupBoard(lastBoardState *rules.BoardState, settings
return StandardMap{}.SetupBoard(lastBoardState, settings, editor)
}
func (m RoyaleHazardsMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
func (m RoyaleHazardsMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}
func (m RoyaleHazardsMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
// Use StandardMap to populate food
if err := (StandardMap{}).UpdateBoard(lastBoardState, settings, editor); err != nil {
if err := (StandardMap{}).PostUpdateBoard(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
turn := lastBoardState.Turn + 1
if settings.RoyaleSettings.ShrinkEveryNTurns < 1 {
shrinkEveryNTurns := settings.Int(rules.ParamShrinkEveryNTurns, 0)
if shrinkEveryNTurns < 1 {
return errors.New("royale game can't shrink more frequently than every turn")
}
if turn < settings.RoyaleSettings.ShrinkEveryNTurns {
if turn < shrinkEveryNTurns {
return nil
}
@ -56,7 +61,7 @@ func (m RoyaleHazardsMap) UpdateBoard(lastBoardState *rules.BoardState, settings
// Get random generator for turn zero, because we're regenerating all hazards every time.
randGenerator := settings.GetRand(0)
numShrinks := turn / settings.RoyaleSettings.ShrinkEveryNTurns
numShrinks := turn / shrinkEveryNTurns
minX, maxX := 0, lastBoardState.Width-1
minY, maxY := 0, lastBoardState.Height-1
for i := 0; i < numShrinks; i++ {

View file

@ -33,8 +33,12 @@ func (m SinkholesMap) SetupBoard(initialBoardState *rules.BoardState, settings r
return (StandardMap{}).SetupBoard(initialBoardState, settings, editor)
}
func (m SinkholesMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
err := StandardMap{}.UpdateBoard(lastBoardState, settings, editor)
func (m SinkholesMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}
func (m SinkholesMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
err := StandardMap{}.PostUpdateBoard(lastBoardState, settings, editor)
if err != nil {
return err
}
@ -42,8 +46,9 @@ func (m SinkholesMap) UpdateBoard(lastBoardState *rules.BoardState, settings rul
currentTurn := lastBoardState.Turn
startTurn := 1
spawnEveryNTurns := 10
if settings.RoyaleSettings.ShrinkEveryNTurns > 0 {
spawnEveryNTurns = settings.RoyaleSettings.ShrinkEveryNTurns
shrinkEveryNTurns := settings.Int(rules.ParamShrinkEveryNTurns, 0)
if shrinkEveryNTurns > 0 {
spawnEveryNTurns = shrinkEveryNTurns
}
maxRings := 5
if lastBoardState.Width == 7 {

View file

@ -38,7 +38,7 @@ func TestSinkholesMap(t *testing.T) {
totalTurns := 100
for i := 0; i < totalTurns; i++ {
state.Turn = i
err = m.UpdateBoard(state, settings, editor)
err = m.PostUpdateBoard(state, settings, editor)
require.NoError(t, err)
}
require.NotEmpty(t, state.Hazards)

View file

@ -4,20 +4,22 @@ import (
"github.com/BattlesnakeOfficial/rules"
)
type SnailModeMap struct{}
type SnailModeMap struct {
lastTailPositions map[rules.Point]int // local state is preserved during the turn
}
// init registers this map in the global registry.
func init() {
globalRegistry.RegisterMap("snail_mode", SnailModeMap{})
globalRegistry.RegisterMap("snail_mode", &SnailModeMap{lastTailPositions: nil})
}
// ID returns a unique identifier for this map.
func (m SnailModeMap) ID() string {
func (m *SnailModeMap) ID() string {
return "snail_mode"
}
// Meta returns the non-functional metadata about this map.
func (m SnailModeMap) Meta() Metadata {
func (m *SnailModeMap) Meta() Metadata {
return Metadata{
Name: "Snail Mode",
Description: "Snakes leave behind a trail of hazards",
@ -31,7 +33,7 @@ func (m SnailModeMap) Meta() Metadata {
}
// SetupBoard here is pretty 'standard' and doesn't do any special setup for this game mode
func (m SnailModeMap) SetupBoard(initialBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
func (m *SnailModeMap) SetupBoard(initialBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
rand := settings.GetRand(0)
if len(initialBoardState.Snakes) > int(m.Meta().MaxPlayers) {
@ -57,23 +59,6 @@ func (m SnailModeMap) SetupBoard(initialBoardState *rules.BoardState, settings r
return nil
}
// storeTailLocation returns an offboard point that corresponds to the given point.
// This is useful for storing state that can be accessed next turn.
func storeTailLocation(point rules.Point, height int) rules.Point {
return rules.Point{X: point.X, Y: point.Y + height}
}
// getPrevTailLocation returns the onboard point that corresponds to an offboard point.
// This is useful for restoring state that was stored last turn.
func getPrevTailLocation(point rules.Point, height int) rules.Point {
return rules.Point{X: point.X, Y: point.Y - height}
}
// outOfBounds determines if the given point is out of bounds for the current board size
func outOfBounds(p rules.Point, w, h int) bool {
return p.X < 0 || p.Y < 0 || p.X >= w || p.Y >= h
}
// doubleTail determine if the snake has a double stacked tail currently
func doubleTail(snake *rules.Snake) bool {
almostTail := snake.Body[len(snake.Body)-2]
@ -86,12 +71,28 @@ func isEliminated(s *rules.Snake) bool {
return s.EliminatedCause != rules.NotEliminated
}
// UpdateBoard does the work of placing the hazards along the 'snail tail' of snakes
// This is responsible for saving the current tail location off the board
// and restoring the previous tail position. This also handles removing one hazards from
// the current stacks so the hazards tails fade as the snake moves away.
func (m SnailModeMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
err := StandardMap{}.UpdateBoard(lastBoardState, settings, editor)
// PreUpdateBoard stores the tail position of each snake in memory, to be
// able to place hazards there after the snakes move.
func (m *SnailModeMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
m.lastTailPositions = make(map[rules.Point]int)
for _, snake := range lastBoardState.Snakes {
if isEliminated(&snake) {
continue
}
// Double tail means that the tail will stay on the same square for more
// than one turn, so we don't want to spawn hazards
if doubleTail(&snake) {
continue
}
m.lastTailPositions[snake.Body[len(snake.Body)-1]] = len(snake.Body)
}
return nil
}
// PostUpdateBoard does the work of placing the hazards along the 'snail tail' of snakes
// This also handles removing one hazards from the current stacks so the hazards tails fade as the snake moves away.
func (m *SnailModeMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
err := StandardMap{}.PostUpdateBoard(lastBoardState, settings, editor)
if err != nil {
return err
}
@ -100,79 +101,38 @@ func (m SnailModeMap) UpdateBoard(lastBoardState *rules.BoardState, settings rul
// need to be cleared first.
editor.ClearHazards()
// This is a list of all the hazards we want to add for the previous tails
// These were stored off board in the previous turn as a way to save state
// When we add the locations to this list we have already converted the off-board
// points to on-board points
tailLocations := make([]rules.Point, 0, len(lastBoardState.Snakes))
// Count the number of hazards for a given position
// Add non-double tail locations to a slice
hazardCounts := map[rules.Point]int{}
for _, hazard := range lastBoardState.Hazards {
// discard out of bound
if outOfBounds(hazard, lastBoardState.Width, lastBoardState.Height) {
onBoardTail := getPrevTailLocation(hazard, lastBoardState.Height)
tailLocations = append(tailLocations, onBoardTail)
} else {
hazardCounts[hazard]++
}
}
// Add back existing hazards, but with a stack of 1 less than before.
// This has the effect of making the snail-trail disappear over time.
for hazard, count := range hazardCounts {
for i := 0; i < count-1; i++ {
editor.AddHazard(hazard)
}
}
// Store a stack of hazards for the tail of each snake. This is stored out
// of bounds and then applied on the next turn. The stack count is equal
// the lenght of the snake.
for _, snake := range lastBoardState.Snakes {
if isEliminated(&snake) {
continue
}
// Double tail means that the tail will stay on the same square for more
// than one turn, so we don't want to spawn hazards
if doubleTail(&snake) {
continue
}
tail := snake.Body[len(snake.Body)-1]
offBoardTail := storeTailLocation(tail, lastBoardState.Height)
for i := 0; i < len(snake.Body); i++ {
editor.AddHazard(offBoardTail)
}
}
// Read offboard tails and move them to the board. The offboard tails are
// stacked based on the length of the snake
for _, p := range tailLocations {
// Skip position if a snakes head occupies it.
// Otherwise hazard shows up in the viewer on top of a snake head, but
// does not damage the snake, which is visually confusing.
isHead := false
// Place a new stack of hazards where each snake's tail used to be
NewHazardLoop:
for location, count := range m.lastTailPositions {
for _, snake := range lastBoardState.Snakes {
if isEliminated(&snake) {
continue
}
head := snake.Body[0]
if p.X == head.X && p.Y == head.Y {
isHead = true
break
if location.X == head.X && location.Y == head.Y {
// Skip position if a snakes head occupies it.
// Otherwise hazard shows up in the viewer on top of a snake head, but
// does not damage the snake, which is visually confusing.
continue NewHazardLoop
}
}
if isHead {
continue
for i := 0; i < count; i++ {
editor.AddHazard(location)
}
editor.AddHazard(p)
}
return nil

View file

@ -176,7 +176,11 @@ func (m SoloMazeMap) PlaceFood(boardState *rules.BoardState, settings rules.Sett
}
}
func (m SoloMazeMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
func (m SoloMazeMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}
func (m SoloMazeMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
currentLevel, e := m.ReadBitState(lastBoardState)
if e != nil {
return e

View file

@ -57,7 +57,11 @@ func (m StandardMap) SetupBoard(initialBoardState *rules.BoardState, settings ru
return nil
}
func (m StandardMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
func (m StandardMap) PreUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
return nil
}
func (m StandardMap) PostUpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error {
rand := settings.GetRand(lastBoardState.Turn)
foodNeeded := checkFoodNeedingPlacement(rand, settings, lastBoardState)
@ -69,8 +73,8 @@ func (m StandardMap) UpdateBoard(lastBoardState *rules.BoardState, settings rule
}
func checkFoodNeedingPlacement(rand rules.Rand, settings rules.Settings, state *rules.BoardState) int {
minFood := int(settings.MinimumFood)
foodSpawnChance := int(settings.FoodSpawnChance)
minFood := settings.Int(rules.ParamMinimumFood, 0)
foodSpawnChance := settings.Int(rules.ParamFoodSpawnChance, 0)
numCurrentFood := len(state.Food)
if numCurrentFood < minFood {

View file

@ -29,65 +29,29 @@ func TestStandardMapSetupBoard(t *testing.T) {
"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{},
},
rules.NewBoardState(7, 7).WithFood([]rules.Point{{X: 3, Y: 3}}),
nil,
},
{
"not enough room for snakes 7x7",
&rules.BoardState{
Width: 7,
Height: 7,
Snakes: generateSnakes(17),
Food: []rules.Point{},
Hazards: []rules.Point{},
},
rules.NewBoardState(7, 7).WithSnakes(generateSnakes(17)),
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.NewBoardState(5, 5).WithSnakes(generateSnakes(14)),
rules.MinRand,
nil,
rules.ErrorTooManySnakes,
},
{
"full 11x11 min",
&rules.BoardState{
Width: 11,
Height: 11,
Snakes: generateSnakes(8),
Food: []rules.Point{},
Hazards: []rules.Point{},
},
rules.NewBoardState(11, 11).WithSnakes(generateSnakes(8)),
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{
rules.NewBoardState(11, 11).
WithFood([]rules.Point{
{X: 0, Y: 2},
{X: 0, Y: 8},
{X: 8, Y: 0},
@ -97,35 +61,25 @@ func TestStandardMapSetupBoard(t *testing.T) {
{X: 4, Y: 10},
{X: 10, Y: 4},
{X: 5, Y: 5},
},
Hazards: []rules.Point{},
},
}).
WithSnakes([]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},
}),
nil,
},
{
"full 11x11 max",
&rules.BoardState{
Width: 11,
Height: 11,
Snakes: generateSnakes(8),
Food: []rules.Point{},
Hazards: []rules.Point{},
},
rules.NewBoardState(11, 11).WithSnakes(generateSnakes(8)),
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{
rules.NewBoardState(11, 11).
WithFood([]rules.Point{
{X: 6, Y: 0},
{X: 6, Y: 10},
{X: 10, Y: 6},
@ -135,9 +89,17 @@ func TestStandardMapSetupBoard(t *testing.T) {
{X: 10, Y: 8},
{X: 2, Y: 0},
{X: 5, Y: 5},
},
Hazards: []rules.Point{},
},
}).
WithSnakes([]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},
}),
nil,
},
}
@ -172,132 +134,51 @@ func TestStandardMapUpdateBoard(t *testing.T) {
{
"empty no food",
rules.NewBoardState(2, 2),
rules.Settings{
FoodSpawnChance: 0,
MinimumFood: 0,
},
rules.NewSettingsWithParams(rules.ParamFoodSpawnChance, "0", rules.ParamMinimumFood, "0"),
rules.MinRand,
&rules.BoardState{
Width: 2,
Height: 2,
Snakes: []rules.Snake{},
Food: []rules.Point{},
Hazards: []rules.Point{},
},
rules.NewBoardState(2, 2),
},
{
"empty MinimumFood",
rules.NewBoardState(2, 2),
rules.Settings{
FoodSpawnChance: 0,
MinimumFood: 2,
},
rules.NewSettingsWithParams(rules.ParamFoodSpawnChance, "0", rules.ParamMinimumFood, "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{},
},
rules.NewBoardState(2, 2).WithFood([]rules.Point{{X: 0, Y: 0}, {X: 0, Y: 1}}),
},
{
"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.NewBoardState(2, 2).WithFood([]rules.Point{{X: 0, Y: 1}}),
rules.NewSettingsWithParams(rules.ParamFoodSpawnChance, "0", rules.ParamMinimumFood, "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{},
},
rules.NewBoardState(2, 2).WithFood([]rules.Point{{X: 0, Y: 1}, {X: 0, Y: 0}}),
},
{
"empty FoodSpawnChance inactive",
rules.NewBoardState(2, 2),
rules.Settings{
FoodSpawnChance: 50,
MinimumFood: 0,
},
rules.NewSettingsWithParams(rules.ParamFoodSpawnChance, "50", rules.ParamMinimumFood, "0"),
rules.MinRand,
&rules.BoardState{
Width: 2,
Height: 2,
Snakes: []rules.Snake{},
Food: []rules.Point{},
Hazards: []rules.Point{},
},
rules.NewBoardState(2, 2),
},
{
"empty FoodSpawnChance active",
rules.NewBoardState(2, 2),
rules.Settings{
FoodSpawnChance: 50,
MinimumFood: 0,
},
rules.NewSettingsWithParams(rules.ParamFoodSpawnChance, "50", rules.ParamMinimumFood, "0"),
rules.MaxRand,
&rules.BoardState{
Width: 2,
Height: 2,
Snakes: []rules.Snake{},
Food: []rules.Point{{X: 0, Y: 1}},
Hazards: []rules.Point{},
},
rules.NewBoardState(2, 2).WithFood([]rules.Point{{X: 0, Y: 1}}),
},
{
"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.NewBoardState(2, 2).WithFood([]rules.Point{{X: 0, Y: 0}}),
rules.NewSettingsWithParams(rules.ParamFoodSpawnChance, "50", rules.ParamMinimumFood, "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{},
},
rules.NewBoardState(2, 2).WithFood([]rules.Point{{X: 0, Y: 0}, {X: 1, Y: 0}}),
},
{
"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.NewBoardState(2, 2).WithFood([]rules.Point{{X: 0, Y: 0}, {X: 0, Y: 1}, {X: 1, Y: 0}, {X: 1, Y: 1}}),
rules.NewSettingsWithParams(rules.ParamFoodSpawnChance, "50", rules.ParamMinimumFood, "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{},
},
rules.NewBoardState(2, 2).WithFood([]rules.Point{{X: 0, Y: 0}, {X: 0, Y: 1}, {X: 1, Y: 0}, {X: 1, Y: 1}}),
},
}
for _, test := range tests {
@ -306,7 +187,7 @@ func TestStandardMapUpdateBoard(t *testing.T) {
settings := test.settings.WithRand(test.rand)
editor := maps.NewBoardStateEditor(nextBoardState)
err := m.UpdateBoard(test.initialBoardState.Clone(), settings, editor)
err := m.PostUpdateBoard(test.initialBoardState.Clone(), settings, editor)
require.NoError(t, err)
require.Equal(t, test.expected, nextBoardState)

View file

@ -38,6 +38,33 @@ var globalRegistry = StageRegistry{
StageMovementWrapBoundaries: MoveSnakesWrapped,
}
// Pipeline is an ordered sequences of game stages which are executed to produce the
// next game state.
//
// If a stage produces an error or an ended game state, the pipeline is halted at that stage.
type Pipeline interface {
// Execute runs the pipeline stages and produces a next game state.
//
// If any stage produces an error or an ended game state, the pipeline
// immediately stops at that stage.
//
// Errors should be checked and the other results ignored if error is non-nil.
//
// If the pipeline is already in an error state (this can be checked by calling Err()),
// this error will be immediately returned and the pipeline will not run.
//
// After the pipeline runs, the results will be the result of the last stage that was executed.
Execute(*BoardState, Settings, []SnakeMove) (bool, *BoardState, error)
// Err provides a way to check for errors before/without calling Execute.
// Err returns an error if the Pipeline is in an error state.
// If this error is not nil, this error will also be returned from Execute, so it is
// optional to call Err.
// The idea is to reduce error-checking verbosity for the majority of cases where a
// Pipeline is immediately executed after construction (i.e. NewPipeline(...).Execute(...)).
Err() error
}
// 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)
@ -46,6 +73,14 @@ var globalRegistry = StageRegistry{
// Errors should be treated as meaning the stage failed and the board state is now invalid.
type StageFunc func(*BoardState, Settings, []SnakeMove) (bool, error)
// IsInitialization checks whether the current state means the game is initialising (turn zero).
// Useful for StageFuncs that need to apply different behaviour on initialisation.
func IsInitialization(b *BoardState, settings Settings, moves []SnakeMove) bool {
// We can safely assume that the game state is in the initialisation phase when
// the turn hasn't advanced and the moves are empty
return b.Turn <= 0 && len(moves) == 0
}
// StageRegistry is a mapping of stage names to stage functions
type StageRegistry map[string]StageFunc
@ -76,32 +111,6 @@ func RegisterPipelineStage(s string, fn StageFunc) {
}
}
// Pipeline is an ordered sequences of game stages which are executed to produce the
// next game state.
//
// If a stage produces an error or an ended game state, the pipeline is halted at that stage.
type Pipeline interface {
// Execute runs the pipeline stages and produces a next game state.
//
// If any stage produces an error or an ended game state, the pipeline
// immediately stops at that stage.
//
// Errors should be checked and the other results ignored if error is non-nil.
//
// If the pipeline is already in an error state (this can be checked by calling Err()),
// this error will be immediately returned and the pipeline will not run.
//
// After the pipeline runs, the results will be the result of the last stage that was executed.
Execute(*BoardState, Settings, []SnakeMove) (bool, *BoardState, error)
// Err provides a way to check for errors before/without calling Execute.
// Err returns an error if the Pipeline is in an error state.
// If this error is not nil, this error will also be returned from Execute, so it is
// optional to call Err.
// The idea is to reduce error-checking verbosity for the majority of cases where a
// Pipeline is immediately executed after construction (i.e. NewPipeline(...).Execute(...)).
Err() error
}
// pipeline is an implementation of Pipeline
type pipeline struct {
// stages is a list of stages that should be executed from slice start to end

View file

@ -27,7 +27,7 @@ func TestPipelineRuleset(t *testing.T) {
name: "test",
pipeline: p,
}
ended, err := pr.IsGameOver(&BoardState{})
ended, _, err := pr.Execute(&BoardState{}, nil)
require.NoError(t, err)
require.True(t, ended)
@ -37,7 +37,7 @@ func TestPipelineRuleset(t *testing.T) {
name: "test",
pipeline: p,
}
ended, err = pr.IsGameOver(&BoardState{})
ended, _, err = pr.Execute(&BoardState{}, nil)
require.NoError(t, err)
require.False(t, ended)
@ -56,10 +56,10 @@ func TestPipelineRuleset(t *testing.T) {
pipeline: p,
}
require.Empty(t, b.Food)
b, err = pr.ModifyInitialBoardState(b)
_, b, err = pr.Execute(b, nil)
require.NoError(t, err)
require.Empty(t, b.Food, "food should not be added on initialisation phase")
b, err = pr.CreateNextBoardState(b, mockSnakeMoves())
_, b, err = pr.Execute(b, mockSnakeMoves())
require.NoError(t, err)
require.NotEmpty(t, b.Food, "fodo should be added now")
}

View file

@ -21,17 +21,17 @@ func TestPipeline(t *testing.T) {
r.RegisterPipelineStage("astage", mockStageFn(false, nil))
p = rules.NewPipelineFromRegistry(r)
require.Equal(t, rules.ErrorNoStages, p.Err())
_, _, err = p.Execute(&rules.BoardState{}, rules.Settings{}, nil)
_, _, err = p.Execute(rules.NewBoardState(0, 0), rules.Settings{}, nil)
require.Equal(t, rules.ErrorNoStages, err)
// test that an unregistered stage name errors
p = rules.NewPipelineFromRegistry(r, "doesntexist")
_, _, err = p.Execute(&rules.BoardState{}, rules.Settings{}, nil)
_, _, err = p.Execute(rules.NewBoardState(0, 0), rules.Settings{}, nil)
require.Equal(t, rules.ErrorStageNotFound, p.Err())
require.Equal(t, rules.ErrorStageNotFound, err)
// simplest case - one stage
ended, next, err := rules.NewPipelineFromRegistry(r, "astage").Execute(&rules.BoardState{}, rules.Settings{}, nil)
ended, next, err := rules.NewPipelineFromRegistry(r, "astage").Execute(rules.NewBoardState(0, 0), rules.Settings{}, nil)
require.NoError(t, err)
require.NoError(t, err)
require.NotNil(t, next)
@ -39,20 +39,20 @@ func TestPipeline(t *testing.T) {
// test that the pipeline short-circuits for a stage that errors
r.RegisterPipelineStage("errors", mockStageFn(false, errors.New("")))
ended, next, err = rules.NewPipelineFromRegistry(r, "errors", "astage").Execute(&rules.BoardState{}, rules.Settings{}, nil)
ended, next, err = rules.NewPipelineFromRegistry(r, "errors", "astage").Execute(rules.NewBoardState(0, 0), rules.Settings{}, nil)
require.Error(t, err)
require.NotNil(t, next)
require.False(t, ended)
// test that the pipeline short-circuits for a stage that ends
r.RegisterPipelineStage("ends", mockStageFn(true, nil))
ended, next, err = rules.NewPipelineFromRegistry(r, "ends", "astage").Execute(&rules.BoardState{}, rules.Settings{}, nil)
ended, next, err = rules.NewPipelineFromRegistry(r, "ends", "astage").Execute(rules.NewBoardState(0, 0), rules.Settings{}, nil)
require.NoError(t, err)
require.NotNil(t, next)
require.True(t, ended)
// test that the pipeline runs normally for multiple stages
ended, next, err = rules.NewPipelineFromRegistry(r, "astage", "ends").Execute(&rules.BoardState{}, rules.Settings{}, nil)
ended, next, err = rules.NewPipelineFromRegistry(r, "astage", "ends").Execute(rules.NewBoardState(0, 0), rules.Settings{}, nil)
require.NoError(t, err)
require.NotNil(t, next)
require.True(t, ended)

View file

@ -14,26 +14,6 @@ var royaleRulesetStages = []string{
StageSpawnHazardsShrinkMap,
}
type RoyaleRuleset struct {
StandardRuleset
ShrinkEveryNTurns int
}
func (r *RoyaleRuleset) Name() string { return GameTypeRoyale }
func (r RoyaleRuleset) Execute(bs *BoardState, s Settings, sm []SnakeMove) (bool, *BoardState, error) {
return NewPipeline(royaleRulesetStages...).Execute(bs, s, sm)
}
func (r *RoyaleRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) {
if r.StandardRuleset.HazardDamagePerTurn < 1 {
return nil, errors.New("royale damage per turn must be greater than zero")
}
_, nextState, err := r.Execute(prevState, r.Settings(), moves)
return nextState, err
}
func PopulateHazardsRoyale(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
if IsInitialization(b, settings, moves) {
return false, nil
@ -43,17 +23,18 @@ func PopulateHazardsRoyale(b *BoardState, settings Settings, moves []SnakeMove)
// Royale uses the current turn to generate hazards, not the previous turn that's in the board state
turn := b.Turn + 1
if settings.RoyaleSettings.ShrinkEveryNTurns < 1 {
shrinkEveryNTurns := settings.Int(ParamShrinkEveryNTurns, 0)
if shrinkEveryNTurns < 1 {
return false, errors.New("royale game can't shrink more frequently than every turn")
}
if turn < settings.RoyaleSettings.ShrinkEveryNTurns {
if turn < shrinkEveryNTurns {
return false, nil
}
randGenerator := settings.GetRand(0)
numShrinks := turn / settings.RoyaleSettings.ShrinkEveryNTurns
numShrinks := turn / shrinkEveryNTurns
minX, maxX := 0, b.Width-1
minY, maxY := 0, b.Height-1
for i := 0; i < numShrinks; i++ {
@ -72,22 +53,10 @@ func PopulateHazardsRoyale(b *BoardState, settings Settings, moves []SnakeMove)
for x := 0; x < b.Width; x++ {
for y := 0; y < b.Height; y++ {
if x < minX || x > maxX || y < minY || y > maxY {
b.Hazards = append(b.Hazards, Point{x, y})
b.Hazards = append(b.Hazards, Point{X: x, Y: y})
}
}
}
return false, nil
}
func (r *RoyaleRuleset) IsGameOver(b *BoardState) (bool, error) {
return GameOverStandard(b, r.Settings(), nil)
}
func (r RoyaleRuleset) Settings() Settings {
s := r.StandardRuleset.Settings()
s.RoyaleSettings = RoyaleSettings{
ShrinkEveryNTurns: r.ShrinkEveryNTurns,
}
return s
}

View file

@ -2,14 +2,19 @@ package rules
import (
"errors"
"fmt"
"math/rand"
"testing"
"github.com/stretchr/testify/require"
)
func TestRoyaleRulesetInterface(t *testing.T) {
var _ Ruleset = (*RoyaleRuleset)(nil)
func getRoyaleRuleset(hazardDamagePerTurn, shrinkEveryNTurns int) Ruleset {
settings := NewSettingsWithParams(
ParamHazardDamagePerTurn, fmt.Sprint(hazardDamagePerTurn),
ParamShrinkEveryNTurns, fmt.Sprint(shrinkEveryNTurns),
)
return NewRulesetBuilder().WithSettings(settings).NamedRuleset(GameTypeRoyale)
}
func TestRoyaleDefaultSanity(t *testing.T) {
@ -19,24 +24,19 @@ func TestRoyaleDefaultSanity(t *testing.T) {
{ID: "2", Body: []Point{{X: 0, Y: 1}}},
},
}
r := RoyaleRuleset{StandardRuleset: StandardRuleset{HazardDamagePerTurn: 1}, ShrinkEveryNTurns: 0}
_, err := r.CreateNextBoardState(boardState, []SnakeMove{{"1", "right"}, {"2", "right"}})
r := getRoyaleRuleset(1, 0)
_, _, err := r.Execute(boardState, []SnakeMove{{"1", "right"}, {"2", "right"}})
require.Error(t, err)
require.Equal(t, errors.New("royale game can't shrink more frequently than every turn"), err)
r = RoyaleRuleset{ShrinkEveryNTurns: 1}
_, err = r.CreateNextBoardState(boardState, []SnakeMove{})
require.Error(t, err)
require.Equal(t, errors.New("royale damage per turn must be greater than zero"), err)
r = RoyaleRuleset{StandardRuleset: StandardRuleset{HazardDamagePerTurn: 1}, ShrinkEveryNTurns: 1}
boardState, err = r.CreateNextBoardState(boardState, []SnakeMove{})
r = getRoyaleRuleset(1, 1)
_, boardState, err = r.Execute(boardState, []SnakeMove{})
require.NoError(t, err)
require.Len(t, boardState.Hazards, 0)
}
func TestRoyaleName(t *testing.T) {
r := RoyaleRuleset{}
r := getRoyaleRuleset(0, 0)
require.Equal(t, "royale", r.Name())
}
@ -57,39 +57,39 @@ func TestRoyaleHazards(t *testing.T) {
{Width: 3, Height: 3, Turn: 9, ShrinkEveryNTurns: 10, ExpectedHazards: []Point{}},
{
Width: 3, Height: 3, Turn: 10, ShrinkEveryNTurns: 10,
ExpectedHazards: []Point{{0, 0}, {0, 1}, {0, 2}},
ExpectedHazards: []Point{{X: 0, Y: 0}, {X: 0, Y: 1}, {X: 0, Y: 2}},
},
{
Width: 3, Height: 3, Turn: 11, ShrinkEveryNTurns: 10,
ExpectedHazards: []Point{{0, 0}, {0, 1}, {0, 2}},
ExpectedHazards: []Point{{X: 0, Y: 0}, {X: 0, Y: 1}, {X: 0, Y: 2}},
},
{
Width: 3, Height: 3, Turn: 19, ShrinkEveryNTurns: 10,
ExpectedHazards: []Point{{0, 0}, {0, 1}, {0, 2}},
ExpectedHazards: []Point{{X: 0, Y: 0}, {X: 0, Y: 1}, {X: 0, Y: 2}},
},
{
Width: 3, Height: 3, Turn: 20, ShrinkEveryNTurns: 10,
ExpectedHazards: []Point{{0, 0}, {0, 1}, {0, 2}, {1, 2}, {2, 2}},
ExpectedHazards: []Point{{X: 0, Y: 0}, {X: 0, Y: 1}, {X: 0, Y: 2}, {X: 1, Y: 2}, {X: 2, Y: 2}},
},
{
Width: 3, Height: 3, Turn: 31, ShrinkEveryNTurns: 10,
ExpectedHazards: []Point{{0, 0}, {0, 1}, {0, 2}, {1, 1}, {1, 2}, {2, 1}, {2, 2}},
ExpectedHazards: []Point{{X: 0, Y: 0}, {X: 0, Y: 1}, {X: 0, Y: 2}, {X: 1, Y: 1}, {X: 1, Y: 2}, {X: 2, Y: 1}, {X: 2, Y: 2}},
},
{
Width: 3, Height: 3, Turn: 42, ShrinkEveryNTurns: 10,
ExpectedHazards: []Point{{0, 0}, {0, 1}, {0, 2}, {1, 0}, {1, 1}, {1, 2}, {2, 0}, {2, 1}, {2, 2}},
ExpectedHazards: []Point{{X: 0, Y: 0}, {X: 0, Y: 1}, {X: 0, Y: 2}, {X: 1, Y: 0}, {X: 1, Y: 1}, {X: 1, Y: 2}, {X: 2, Y: 0}, {X: 2, Y: 1}, {X: 2, Y: 2}},
},
{
Width: 3, Height: 3, Turn: 53, ShrinkEveryNTurns: 10,
ExpectedHazards: []Point{{0, 0}, {0, 1}, {0, 2}, {1, 0}, {1, 1}, {1, 2}, {2, 0}, {2, 1}, {2, 2}},
ExpectedHazards: []Point{{X: 0, Y: 0}, {X: 0, Y: 1}, {X: 0, Y: 2}, {X: 1, Y: 0}, {X: 1, Y: 1}, {X: 1, Y: 2}, {X: 2, Y: 0}, {X: 2, Y: 1}, {X: 2, Y: 2}},
},
{
Width: 3, Height: 3, Turn: 64, ShrinkEveryNTurns: 10,
ExpectedHazards: []Point{{0, 0}, {0, 1}, {0, 2}, {1, 0}, {1, 1}, {1, 2}, {2, 0}, {2, 1}, {2, 2}},
ExpectedHazards: []Point{{X: 0, Y: 0}, {X: 0, Y: 1}, {X: 0, Y: 2}, {X: 1, Y: 0}, {X: 1, Y: 1}, {X: 1, Y: 2}, {X: 2, Y: 0}, {X: 2, Y: 1}, {X: 2, Y: 2}},
},
{
Width: 3, Height: 3, Turn: 6987, ShrinkEveryNTurns: 10,
ExpectedHazards: []Point{{0, 0}, {0, 1}, {0, 2}, {1, 0}, {1, 1}, {1, 2}, {2, 0}, {2, 1}, {2, 2}},
ExpectedHazards: []Point{{X: 0, Y: 0}, {X: 0, Y: 1}, {X: 0, Y: 2}, {X: 1, Y: 0}, {X: 1, Y: 1}, {X: 1, Y: 2}, {X: 2, Y: 0}, {X: 2, Y: 1}, {X: 2, Y: 2}},
},
}
@ -99,12 +99,10 @@ func TestRoyaleHazards(t *testing.T) {
Width: test.Width,
Height: test.Height,
}
settings := Settings{
HazardDamagePerTurn: 1,
RoyaleSettings: RoyaleSettings{
ShrinkEveryNTurns: test.ShrinkEveryNTurns,
},
}.WithSeed(seed)
settings := NewSettingsWithParams(
ParamHazardDamagePerTurn, "1",
ParamShrinkEveryNTurns, fmt.Sprint(test.ShrinkEveryNTurns),
).WithSeed(seed)
_, err := PopulateHazardsRoyale(b, settings, mockSnakeMoves())
require.Equal(t, test.Error, err)
@ -139,12 +137,12 @@ var royaleCaseHazardsPlaced = gameTestCase{
Snakes: []Snake{
{
ID: "one",
Body: []Point{{1, 1}, {1, 2}},
Body: []Point{{X: 1, Y: 1}, {X: 1, Y: 2}},
Health: 100,
},
{
ID: "two",
Body: []Point{{3, 4}, {3, 3}},
Body: []Point{{X: 3, Y: 4}, {X: 3, Y: 3}},
Health: 100,
},
{
@ -154,7 +152,7 @@ var royaleCaseHazardsPlaced = gameTestCase{
EliminatedCause: EliminatedByOutOfBounds,
},
},
Food: []Point{{0, 0}, {1, 0}},
Food: []Point{{X: 0, Y: 0}, {X: 1, Y: 0}},
Hazards: []Point{},
},
[]SnakeMove{
@ -169,12 +167,12 @@ var royaleCaseHazardsPlaced = gameTestCase{
Snakes: []Snake{
{
ID: "one",
Body: []Point{{1, 0}, {1, 1}, {1, 1}},
Body: []Point{{X: 1, Y: 0}, {X: 1, Y: 1}, {X: 1, Y: 1}},
Health: 100,
},
{
ID: "two",
Body: []Point{{3, 5}, {3, 4}},
Body: []Point{{X: 3, Y: 5}, {X: 3, Y: 4}},
Health: 99,
},
{
@ -184,7 +182,7 @@ var royaleCaseHazardsPlaced = gameTestCase{
EliminatedCause: EliminatedByOutOfBounds,
},
},
Food: []Point{{0, 0}},
Food: []Point{{X: 0, Y: 0}},
Hazards: []Point{{X: 0, Y: 0}, {X: 1, Y: 0}, {X: 2, Y: 0}, {X: 3, Y: 0}, {X: 4, Y: 0}, {X: 5, Y: 0}, {X: 6, Y: 0}, {X: 7, Y: 0}, {X: 8, Y: 0}, {X: 9, Y: 0}},
},
}
@ -204,22 +202,14 @@ func TestRoyaleCreateNextBoardState(t *testing.T) {
*s2,
royaleCaseHazardsPlaced,
}
r := RoyaleRuleset{
StandardRuleset: StandardRuleset{
HazardDamagePerTurn: 1,
},
ShrinkEveryNTurns: 1,
}
rb := NewRulesetBuilder().WithParams(map[string]string{
ParamGameType: GameTypeRoyale,
ParamHazardDamagePerTurn: "1",
ParamShrinkEveryNTurns: "1",
}).WithSeed(1234)
for _, gc := range cases {
rand.Seed(1234)
gc.requireValidNextState(t, &r)
// also test a RulesBuilder constructed instance
gc.requireValidNextState(t, rb.Ruleset())
// test a RulesBuilder constructed instance
gc.requireValidNextState(t, rb.NamedRuleset(GameTypeRoyale))
// also test a pipeline with the same settings
gc.requireValidNextState(t, rb.PipelineRuleset(GameTypeRoyale, NewPipeline(royaleRulesetStages...)))
}

View file

@ -1,16 +1,15 @@
package rules
import (
"strconv"
)
type Ruleset interface {
// Returns the name of the ruleset, if applicable.
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.
// Returns the settings used by the ruleset.
Settings() Settings
// Processes the next turn of the ruleset, returning whether the game has ended, the next BoardState, or an error.
// For turn zero (initialization), moves will be left empty.
Execute(prevState *BoardState, moves []SnakeMove) (gameOver bool, nextState *BoardState, err error)
}
type SnakeMove struct {
@ -18,68 +17,12 @@ type SnakeMove struct {
Move string
}
// 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 int `json:"foodSpawnChance"`
MinimumFood int `json:"minimumFood"`
HazardDamagePerTurn int `json:"hazardDamagePerTurn"`
HazardMap string `json:"hazardMap"`
HazardMapAuthor string `json:"hazardMapAuthor"`
RoyaleSettings RoyaleSettings `json:"royale"`
SquadSettings SquadSettings `json:"squad"` // Deprecated, provided with default fields for API compatibility
rand Rand
seed int64
}
// Get a random number generator initialized based on the seed and current turn.
func (settings Settings) GetRand(turn int) Rand {
// Allow overriding the random generator for testing
if settings.rand != nil {
return settings.rand
}
if settings.seed != 0 {
return NewSeedRand(settings.seed + int64(turn))
}
// Default to global random number generator if neither seed or rand are set.
return GlobalRand
}
func (settings Settings) WithRand(rand Rand) Settings {
settings.rand = rand
return settings
}
func (settings Settings) Seed() int64 {
return settings.seed
}
func (settings Settings) WithSeed(seed int64) Settings {
settings.seed = seed
return settings
}
// RoyaleSettings contains settings that are specific to the "royale" game mode
type RoyaleSettings struct {
ShrinkEveryNTurns int `json:"shrinkEveryNTurns"`
}
// SquadSettings contains settings that are specific to the "squad" game mode
type SquadSettings struct {
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
solo bool // if true, only 1 alive snake is required to keep the game from ending
settings *Settings // used to set settings directly instead of via string params
}
// NewRulesetBuilder returns an instance of a builder for the Ruleset types.
@ -89,7 +32,7 @@ func NewRulesetBuilder() *rulesetBuilder {
}
}
// WithParams accepts a map of game parameters for customizing games.
// WithParams accepts a map of string parameters for customizing games.
//
// Parameters are copied. If called multiple times, parameters are merged such that:
// - existing keys in both maps get overwritten by the new ones
@ -125,13 +68,14 @@ func (rb *rulesetBuilder) WithSolo(value bool) *rulesetBuilder {
return rb
}
// Ruleset constructs a customised ruleset using the parameters passed to the builder.
func (rb rulesetBuilder) Ruleset() PipelineRuleset {
name, ok := rb.params[ParamGameType]
if !ok {
name = GameTypeStandard
}
// WithSettings sets the settings object for the ruleset directly.
func (rb *rulesetBuilder) WithSettings(settings Settings) *rulesetBuilder {
rb.settings = &settings
return rb
}
// NamedRuleset constructs a known ruleset by using name to look up a standard pipeline.
func (rb rulesetBuilder) NamedRuleset(name string) Ruleset {
var stages []string
if rb.solo {
stages = append(stages, StageGameOverSoloSnake)
@ -153,63 +97,28 @@ func (rb rulesetBuilder) Ruleset() PipelineRuleset {
case GameTypeWrapped:
stages = append(stages, wrappedRulesetStages[1:]...)
default:
name = GameTypeStandard
stages = append(stages, standardRulesetStages[1:]...)
}
return rb.PipelineRuleset(name, NewPipeline(stages...))
}
// PipelineRuleset provides an implementation of the Ruleset using a pipeline with a name.
// It is intended to facilitate transitioning away from legacy Ruleset implementations to Pipeline
// implementations.
func (rb rulesetBuilder) PipelineRuleset(name string, p Pipeline) PipelineRuleset {
// PipelineRuleset constructs a ruleset with the given name and pipeline using the parameters passed to the builder.
// This can be used to create custom rulesets.
func (rb rulesetBuilder) PipelineRuleset(name string, p Pipeline) Ruleset {
var settings Settings
if rb.settings != nil {
settings = *rb.settings
} else {
settings = NewSettings(rb.params).WithRand(rb.rand).WithSeed(rb.seed)
}
return &pipelineRuleset{
name: name,
pipeline: p,
settings: Settings{
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],
RoyaleSettings: RoyaleSettings{
ShrinkEveryNTurns: paramsInt(rb.params, ParamShrinkEveryNTurns, 0),
},
rand: rb.rand,
seed: rb.seed,
},
settings: settings,
}
}
// paramsBool returns the boolean value for the specified parameter.
// If the parameter doesn't exist, the default value will be returned.
// If the parameter does exist, but is not "true", false will be returned.
func paramsBool(params map[string]string, paramName string, defaultValue bool) bool {
if val, ok := params[paramName]; ok {
return val == "true"
}
return defaultValue
}
// paramsInt returns the int value for the specified parameter.
// If the parameter doesn't exist, the default value will be returned.
// If the parameter does exist, but is not a valid int, the default value will be returned.
func paramsInt(params map[string]string, paramName string, defaultValue int) int {
if val, ok := params[paramName]; ok {
i, err := strconv.Atoi(val)
if err == nil {
return i
}
}
return defaultValue
}
// PipelineRuleset groups the Pipeline and Ruleset methods.
// It is intended to facilitate a transition from Ruleset legacy code to Pipeline code.
type PipelineRuleset interface {
Ruleset
Pipeline
}
type pipelineRuleset struct {
pipeline Pipeline
name string
@ -225,33 +134,10 @@ func (r pipelineRuleset) Settings() Settings {
func (r pipelineRuleset) Name() string { return r.name }
// impl Ruleset
// IMPORTANT: this implementation of IsGameOver deviates from the previous Ruleset implementations
// in that it checks if the *NEXT* state results in game over, not the previous state.
// This is due to the design of pipelines / stage functions not having a distinction between
// checking for game over and producing a next state.
func (r *pipelineRuleset) IsGameOver(b *BoardState) (bool, error) {
gameover, _, err := r.Execute(b, r.Settings(), nil) // checks if next state is game over
return gameover, err
func (r pipelineRuleset) Execute(bs *BoardState, sm []SnakeMove) (bool, *BoardState, error) {
return r.pipeline.Execute(bs, r.Settings(), sm)
}
// impl Ruleset
func (r pipelineRuleset) ModifyInitialBoardState(initialState *BoardState) (*BoardState, error) {
_, nextState, err := r.Execute(initialState, r.Settings(), nil)
return nextState, err
}
// impl Pipeline
func (r pipelineRuleset) Execute(bs *BoardState, s Settings, sm []SnakeMove) (bool, *BoardState, error) {
return r.pipeline.Execute(bs, s, sm)
}
// impl Ruleset
func (r pipelineRuleset) CreateNextBoardState(bs *BoardState, sm []SnakeMove) (*BoardState, error) {
_, nextState, err := r.Execute(bs, r.Settings(), sm)
return nextState, err
}
// impl Pipeline
func (r pipelineRuleset) Err() error {
return r.pipeline.Err()
}

View file

@ -10,31 +10,6 @@ import (
_ "github.com/BattlesnakeOfficial/rules/test"
)
func TestParamInt(t *testing.T) {
require.Equal(t, 5, paramsInt(nil, "test", 5), "nil map")
require.Equal(t, 10, paramsInt(map[string]string{}, "foo", 10), "empty map")
require.Equal(t, 10, paramsInt(map[string]string{"hullo": "there"}, "hullo", 10), "invalid value")
require.Equal(t, 20, paramsInt(map[string]string{"bonjour": "20"}, "bonjour", 20), "valid value")
}
func TestParamBool(t *testing.T) {
// missing values default to specified value
require.Equal(t, true, paramsBool(nil, "test", true), "nil map true")
require.Equal(t, false, paramsBool(nil, "test", false), "nil map false")
// missing values default to specified value
require.Equal(t, true, paramsBool(map[string]string{}, "foo", true), "empty map true")
require.Equal(t, false, paramsBool(map[string]string{}, "foo", false), "empty map false")
// invalid values (exist but not booL) default to false
require.Equal(t, false, paramsBool(map[string]string{"hullo": "there"}, "hullo", true), "invalid value default true")
require.Equal(t, false, paramsBool(map[string]string{"hullo": "there"}, "hullo", false), "invalid value default false")
// valid values ignore defaults
require.Equal(t, false, paramsBool(map[string]string{"bonjour": "false"}, "bonjour", false), "valid value false")
require.Equal(t, true, paramsBool(map[string]string{"bonjour": "true"}, "bonjour", false), "valid value true")
}
func TestRulesetError(t *testing.T) {
err := (error)(RulesetError("test error string"))
require.Equal(t, "test error string", err.Error())
@ -42,10 +17,10 @@ func TestRulesetError(t *testing.T) {
func TestRulesetBuilderInternals(t *testing.T) {
// test Royale with seed
rsb := NewRulesetBuilder().WithSeed(3).WithParams(map[string]string{ParamGameType: GameTypeRoyale})
rsb := NewRulesetBuilder().WithSeed(3)
require.Equal(t, int64(3), rsb.seed)
require.Equal(t, GameTypeRoyale, rsb.Ruleset().Name())
require.Equal(t, int64(3), rsb.Ruleset().Settings().Seed())
require.Equal(t, GameTypeRoyale, rsb.NamedRuleset(GameTypeRoyale).Name())
require.Equal(t, int64(3), rsb.NamedRuleset(GameTypeRoyale).Settings().Seed())
// test parameter merging
rsb = NewRulesetBuilder().

View file

@ -5,102 +5,13 @@ import (
"testing"
"github.com/BattlesnakeOfficial/rules"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStandardRulesetSettings(t *testing.T) {
ruleset := rules.StandardRuleset{
MinimumFood: 5,
FoodSpawnChance: 10,
HazardDamagePerTurn: 10,
HazardMap: "hz_spiral",
HazardMapAuthor: "altersaddle",
}
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 TestWrappedRulesetSettings(t *testing.T) {
ruleset := rules.WrappedRuleset{
StandardRuleset: rules.StandardRuleset{
MinimumFood: 5,
FoodSpawnChance: 10,
HazardDamagePerTurn: 10,
HazardMap: "hz_spiral",
HazardMapAuthor: "altersaddle",
},
}
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 TestSoloRulesetSettings(t *testing.T) {
ruleset := rules.SoloRuleset{
StandardRuleset: rules.StandardRuleset{
MinimumFood: 5,
FoodSpawnChance: 10,
HazardDamagePerTurn: 10,
HazardMap: "hz_spiral",
HazardMapAuthor: "altersaddle",
},
}
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 TestRoyaleRulesetSettings(t *testing.T) {
ruleset := rules.RoyaleRuleset{
ShrinkEveryNTurns: 12,
StandardRuleset: rules.StandardRuleset{
MinimumFood: 5,
FoodSpawnChance: 10,
HazardDamagePerTurn: 10,
HazardMap: "hz_spiral",
HazardMapAuthor: "altersaddle",
},
}
assert.Equal(t, ruleset.ShrinkEveryNTurns, ruleset.Settings().RoyaleSettings.ShrinkEveryNTurns)
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 TestConstrictorRulesetSettings(t *testing.T) {
ruleset := rules.ConstrictorRuleset{
StandardRuleset: rules.StandardRuleset{
MinimumFood: 5,
FoodSpawnChance: 10,
HazardDamagePerTurn: 10,
HazardMap: "hz_spiral",
HazardMapAuthor: "altersaddle",
},
}
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) {
// Test that a fresh instance can produce a Ruleset
require.NotNil(t, rules.NewRulesetBuilder().Ruleset())
require.Equal(t, rules.GameTypeStandard, rules.NewRulesetBuilder().Ruleset().Name(), "should default to standard game")
// test nil safety / defaults
require.NotNil(t, rules.NewRulesetBuilder().Ruleset())
require.NotNil(t, rules.NewRulesetBuilder().NamedRuleset(""))
require.Equal(t, rules.GameTypeStandard, rules.NewRulesetBuilder().NamedRuleset("").Name(), "should default to standard game")
// make sure it works okay for lots of game types
expectedResults := []struct {
@ -120,32 +31,23 @@ func TestRulesetBuilder(t *testing.T) {
rsb.WithParams(map[string]string{
// apply the standard rule params
rules.ParamGameType: expected.GameType,
rules.ParamFoodSpawnChance: "10",
rules.ParamMinimumFood: "5",
rules.ParamHazardDamagePerTurn: "12",
rules.ParamHazardMap: "test",
rules.ParamHazardMapAuthor: "tester",
})
require.NotNil(t, rsb.Ruleset())
require.Equal(t, expected.GameType, rsb.Ruleset().Name())
require.NotNil(t, rsb.NamedRuleset(expected.GameType))
require.Equal(t, expected.GameType, rsb.NamedRuleset(expected.GameType).Name())
// All the standard settings should always be copied over
require.Equal(t, 10, rsb.Ruleset().Settings().FoodSpawnChance)
require.Equal(t, 12, rsb.Ruleset().Settings().HazardDamagePerTurn)
require.Equal(t, 5, rsb.Ruleset().Settings().MinimumFood)
require.Equal(t, "test", rsb.Ruleset().Settings().HazardMap)
require.Equal(t, "tester", rsb.Ruleset().Settings().HazardMapAuthor)
require.Equal(t, 10, rsb.NamedRuleset(expected.GameType).Settings().Int(rules.ParamFoodSpawnChance, 0))
require.Equal(t, 12, rsb.NamedRuleset(expected.GameType).Settings().Int(rules.ParamHazardDamagePerTurn, 0))
require.Equal(t, 5, rsb.NamedRuleset(expected.GameType).Settings().Int(rules.ParamMinimumFood, 0))
})
}
}
func TestRulesetBuilderGameOver(t *testing.T) {
settings := rules.Settings{
RoyaleSettings: rules.RoyaleSettings{
ShrinkEveryNTurns: 12,
},
}
settings := rules.NewSettingsWithParams(rules.ParamShrinkEveryNTurns, "12")
moves := []rules.SnakeMove{
{ID: "1", Move: "up"},
}
@ -214,13 +116,11 @@ func TestRulesetBuilderGameOver(t *testing.T) {
for _, test := range tests {
t.Run(fmt.Sprintf("%v_%v", test.gameType, test.solo), func(t *testing.T) {
rsb := rules.NewRulesetBuilder().WithParams(map[string]string{
rules.ParamGameType: test.gameType,
}).WithSolo(test.solo)
rsb := rules.NewRulesetBuilder().WithSettings(settings).WithSolo(test.solo)
ruleset := rsb.Ruleset()
ruleset := rsb.NamedRuleset(test.gameType)
gameOver, _, err := ruleset.Execute(boardState, settings, moves)
gameOver, _, err := ruleset.Execute(boardState, moves)
require.NoError(t, err)
require.Equal(t, test.gameOver, gameOver)
@ -234,7 +134,7 @@ func TestStageFuncContract(t *testing.T) {
stage = func(bs *rules.BoardState, s rules.Settings, sm []rules.SnakeMove) (bool, error) {
return true, nil
}
ended, err := stage(nil, rules.NewRulesetBuilder().Ruleset().Settings(), nil)
ended, err := stage(nil, rules.NewRulesetBuilder().NamedRuleset("").Settings(), nil)
require.NoError(t, err)
require.True(t, ended)
}

90
settings.go Normal file
View file

@ -0,0 +1,90 @@
package rules
import "strconv"
// Settings contains all settings relevant to a game.
// The settings are stored as raw string values, which should not be accessed
// directly. Calling code should instead use the Int/Bool methods to parse them.
type Settings struct {
rawValues map[string]string
rand Rand
seed int64
}
func NewSettings(params map[string]string) Settings {
rawValues := make(map[string]string, len(params))
// Copy incoming params into a new map
for key, value := range params {
rawValues[key] = value
}
return Settings{
rawValues: rawValues,
}
}
func NewSettingsWithParams(params ...string) Settings {
rawValues := map[string]string{}
for index := 1; index < len(params); index += 2 {
rawValues[params[index-1]] = params[index]
}
return Settings{
rawValues: rawValues,
}
}
// Get a random number generator initialized based on the seed and current turn.
func (settings Settings) GetRand(turn int) Rand {
// Allow overriding the random generator for testing
if settings.rand != nil {
return settings.rand
}
if settings.seed != 0 {
return NewSeedRand(settings.seed + int64(turn))
}
// Default to global random number generator if neither seed or rand are set.
return GlobalRand
}
func (settings Settings) WithRand(rand Rand) Settings {
settings.rand = rand
return settings
}
func (settings Settings) Seed() int64 {
return settings.seed
}
func (settings Settings) WithSeed(seed int64) Settings {
settings.seed = seed
return settings
}
// Bool returns the boolean value for the specified parameter.
// If the parameter doesn't exist, the default value will be returned.
// If the parameter does exist, but is not "true", false will be returned.
func (settings Settings) Bool(paramName string, defaultValue bool) bool {
if val, ok := settings.rawValues[paramName]; ok {
return val == "true"
}
return defaultValue
}
// Int returns the int value for the specified parameter.
// If the parameter doesn't exist, the default value will be returned.
// If the parameter does exist, but is not a valid int, the default value will be returned.
func (settings Settings) Int(paramName string, defaultValue int) int {
if val, ok := settings.rawValues[paramName]; ok {
i, err := strconv.Atoi(val)
if err == nil {
return i
}
}
return defaultValue
}

31
settings_test.go Normal file
View file

@ -0,0 +1,31 @@
package rules_test
import (
"testing"
"github.com/BattlesnakeOfficial/rules"
"github.com/stretchr/testify/assert"
)
func TestSettings(t *testing.T) {
params := map[string]string{
"invalidSetting": "abcd",
"intSetting": "1234",
"boolSetting": "true",
}
settings := rules.NewSettings(params)
assert.Equal(t, 4567, settings.Int("missingIntSetting", 4567))
assert.Equal(t, 4567, settings.Int("invalidSetting", 4567))
assert.Equal(t, 1234, settings.Int("intSetting", 4567))
assert.Equal(t, false, settings.Bool("missingBoolSetting", false))
assert.Equal(t, true, settings.Bool("missingBoolSetting", true))
assert.Equal(t, false, settings.Bool("invalidSetting", true))
assert.Equal(t, true, settings.Bool("boolSetting", true))
assert.Equal(t, 4567, rules.NewSettingsWithParams("newIntSetting").Int("newIntSetting", 4567))
assert.Equal(t, 1234, rules.NewSettingsWithParams("newIntSetting", "1234").Int("newIntSetting", 4567))
assert.Equal(t, 4567, rules.NewSettingsWithParams("x", "y", "newIntSetting").Int("newIntSetting", 4567))
}

19
solo.go
View file

@ -9,25 +9,6 @@ var soloRulesetStages = []string{
StageEliminationStandard,
}
type SoloRuleset struct {
StandardRuleset
}
func (r *SoloRuleset) Name() string { return GameTypeSolo }
func (r SoloRuleset) Execute(bs *BoardState, s Settings, sm []SnakeMove) (bool, *BoardState, error) {
return NewPipeline(soloRulesetStages...).Execute(bs, s, sm)
}
func (r *SoloRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) {
_, nextState, err := r.Execute(prevState, r.Settings(), moves)
return nextState, err
}
func (r *SoloRuleset) IsGameOver(b *BoardState) (bool, error) {
return GameOverSolo(b, r.Settings(), nil)
}
func GameOverSolo(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
for i := 0; i < len(b.Snakes); i++ {
if b.Snakes[i].EliminatedCause == NotEliminated {

View file

@ -6,20 +6,21 @@ import (
"github.com/stretchr/testify/require"
)
func TestSoloRulesetInterface(t *testing.T) {
var _ Ruleset = (*SoloRuleset)(nil)
func getSoloRuleset(settings Settings) Ruleset {
return NewRulesetBuilder().WithSettings(settings).NamedRuleset(GameTypeSolo)
}
func TestSoloName(t *testing.T) {
r := SoloRuleset{}
r := getSoloRuleset(Settings{})
require.Equal(t, "solo", r.Name())
}
func TestSoloCreateNextBoardStateSanity(t *testing.T) {
boardState := &BoardState{}
r := SoloRuleset{}
_, err := r.CreateNextBoardState(boardState, []SnakeMove{})
r := getSoloRuleset(Settings{})
gameOver, _, err := r.Execute(boardState, []SnakeMove{})
require.NoError(t, err)
require.True(t, gameOver)
}
func TestSoloIsGameOver(t *testing.T) {
@ -41,7 +42,7 @@ func TestSoloIsGameOver(t *testing.T) {
},
}
r := SoloRuleset{}
r := getSoloRuleset(Settings{})
for _, test := range tests {
b := &BoardState{
Height: 11,
@ -50,7 +51,7 @@ func TestSoloIsGameOver(t *testing.T) {
Food: []Point{},
}
actual, err := r.IsGameOver(b)
actual, _, err := r.Execute(b, nil)
require.NoError(t, err)
require.Equal(t, test.Expected, actual)
}
@ -69,11 +70,11 @@ var soloCaseNotOver = gameTestCase{
Snakes: []Snake{
{
ID: "one",
Body: []Point{{1, 1}, {1, 2}},
Body: []Point{{X: 1, Y: 1}, {X: 1, Y: 2}},
Health: 100,
},
},
Food: []Point{{0, 0}, {1, 0}},
Food: []Point{{X: 0, Y: 0}, {X: 1, Y: 0}},
Hazards: []Point{},
},
[]SnakeMove{
@ -86,11 +87,11 @@ var soloCaseNotOver = gameTestCase{
Snakes: []Snake{
{
ID: "one",
Body: []Point{{1, 0}, {1, 1}, {1, 1}},
Body: []Point{{X: 1, Y: 0}, {X: 1, Y: 1}, {X: 1, Y: 1}},
Health: 100,
},
},
Food: []Point{{0, 0}},
Food: []Point{{X: 0, Y: 0}},
Hazards: []Point{},
},
}
@ -104,14 +105,10 @@ func TestSoloCreateNextBoardState(t *testing.T) {
standardMoveAndCollideMAD,
soloCaseNotOver,
}
r := SoloRuleset{}
rb := NewRulesetBuilder().WithParams(map[string]string{
ParamGameType: GameTypeSolo,
})
r := getSoloRuleset(Settings{})
for _, gc := range cases {
gc.requireValidNextState(t, &r)
// also test a RulesBuilder constructed instance
gc.requireValidNextState(t, rb.Ruleset())
// test a RulesBuilder constructed instance
gc.requireValidNextState(t, r)
// also test a pipeline with the same settings
gc.requireValidNextState(t, NewRulesetBuilder().PipelineRuleset(GameTypeSolo, NewPipeline(soloRulesetStages...)))
}
@ -119,26 +116,18 @@ func TestSoloCreateNextBoardState(t *testing.T) {
// Test a snake running right into the wall is properly eliminated
func TestSoloEliminationOutOfBounds(t *testing.T) {
r := SoloRuleset{}
r := getSoloRuleset(Settings{})
// Using MaxRand is important because it ensures that the snakes are consistently placed in a way this test will work.
// Actually random placement could result in the assumptions made by this test being incorrect.
initialState, err := CreateDefaultBoardState(MaxRand, 2, 2, []string{"one"})
require.NoError(t, err)
_, next, err := r.Execute(
initialState,
r.Settings(),
[]SnakeMove{{ID: "one", Move: "right"}},
)
_, next, err := r.Execute(initialState, []SnakeMove{{ID: "one", Move: "right"}})
require.NoError(t, err)
require.NotNil(t, initialState)
ended, next, err := r.Execute(
next,
r.Settings(),
[]SnakeMove{{ID: "one", Move: "right"}},
)
ended, next, err := r.Execute(next, []SnakeMove{{ID: "one", Move: "right"}})
require.NoError(t, err)
require.NotNil(t, initialState)

View file

@ -5,14 +5,6 @@ import (
"sort"
)
type StandardRuleset struct {
FoodSpawnChance int // [0, 100]
MinimumFood int
HazardDamagePerTurn int
HazardMap string // optional
HazardMapAuthor string // optional
}
var standardRulesetStages = []string{
StageGameOverStandard,
StageMovementStandard,
@ -22,23 +14,6 @@ var standardRulesetStages = []string{
StageEliminationStandard,
}
func (r *StandardRuleset) Name() string { return GameTypeStandard }
func (r *StandardRuleset) ModifyInitialBoardState(initialState *BoardState) (*BoardState, error) {
// No-op
return initialState, nil
}
// impl Pipeline
func (r StandardRuleset) Execute(bs *BoardState, s Settings, sm []SnakeMove) (bool, *BoardState, error) {
return NewPipeline(standardRulesetStages...).Execute(bs, s, sm)
}
func (r *StandardRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) {
_, nextState, err := r.Execute(prevState, r.Settings(), moves)
return nextState, err
}
func MoveSnakesStandard(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
if IsInitialization(b, settings, moves) {
return false, nil
@ -156,6 +131,7 @@ func DamageHazardsStandard(b *BoardState, settings Settings, moves []SnakeMove)
if IsInitialization(b, settings, moves) {
return false, nil
}
hazardDamage := settings.Int(ParamHazardDamagePerTurn, 0)
for i := 0; i < len(b.Snakes); i++ {
snake := &b.Snakes[i]
if snake.EliminatedCause != NotEliminated {
@ -176,7 +152,7 @@ func DamageHazardsStandard(b *BoardState, settings Settings, moves []SnakeMove)
}
// Snake is in a hazard, reduce health
snake.Health = snake.Health - settings.HazardDamagePerTurn
snake.Health = snake.Health - hazardDamage
if snake.Health < 0 {
snake.Health = 0
}
@ -393,20 +369,18 @@ func SpawnFoodStandard(b *BoardState, settings Settings, moves []SnakeMove) (boo
if IsInitialization(b, settings, moves) {
return false, nil
}
minimumFood := settings.Int(ParamMinimumFood, 0)
foodSpawnChance := settings.Int(ParamFoodSpawnChance, 0)
numCurrentFood := int(len(b.Food))
if numCurrentFood < settings.MinimumFood {
return false, PlaceFoodRandomly(GlobalRand, b, settings.MinimumFood-numCurrentFood)
if numCurrentFood < minimumFood {
return false, PlaceFoodRandomly(GlobalRand, b, minimumFood-numCurrentFood)
}
if settings.FoodSpawnChance > 0 && int(rand.Intn(100)) < settings.FoodSpawnChance {
if foodSpawnChance > 0 && int(rand.Intn(100)) < foodSpawnChance {
return false, PlaceFoodRandomly(GlobalRand, b, 1)
}
return false, nil
}
func (r *StandardRuleset) IsGameOver(b *BoardState) (bool, error) {
return GameOverStandard(b, r.Settings(), nil)
}
func GameOverStandard(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
numSnakesRemaining := 0
for i := 0; i < len(b.Snakes); i++ {
@ -416,25 +390,3 @@ func GameOverStandard(b *BoardState, settings Settings, moves []SnakeMove) (bool
}
return numSnakesRemaining <= 1, nil
}
func (r StandardRuleset) Settings() Settings {
return Settings{
FoodSpawnChance: r.FoodSpawnChance,
MinimumFood: r.MinimumFood,
HazardDamagePerTurn: r.HazardDamagePerTurn,
HazardMap: r.HazardMap,
HazardMapAuthor: r.HazardMapAuthor,
}
}
// impl Pipeline
func (r StandardRuleset) Err() error {
return nil
}
// IsInitialization checks whether the current state means the game is initialising.
func IsInitialization(b *BoardState, settings Settings, moves []SnakeMove) bool {
// We can safely assume that the game state is in the initialisation phase when
// the turn hasn't advanced and the moves are empty
return b.Turn <= 0 && len(moves) == 0
}

File diff suppressed because it is too large Load diff

View file

@ -9,22 +9,6 @@ var wrappedRulesetStages = []string{
StageEliminationStandard,
}
type WrappedRuleset struct {
StandardRuleset
}
func (r *WrappedRuleset) Name() string { return GameTypeWrapped }
func (r WrappedRuleset) Execute(bs *BoardState, s Settings, sm []SnakeMove) (bool, *BoardState, error) {
return NewPipeline(wrappedRulesetStages...).Execute(bs, s, sm)
}
func (r *WrappedRuleset) CreateNextBoardState(prevState *BoardState, moves []SnakeMove) (*BoardState, error) {
_, nextState, err := r.Execute(prevState, r.Settings(), moves)
return nextState, err
}
func MoveSnakesWrapped(b *BoardState, settings Settings, moves []SnakeMove) (bool, error) {
if IsInitialization(b, settings, moves) {
return false, nil
@ -47,10 +31,6 @@ func MoveSnakesWrapped(b *BoardState, settings Settings, moves []SnakeMove) (boo
return false, nil
}
func (r *WrappedRuleset) IsGameOver(b *BoardState) (bool, error) {
return GameOverStandard(b, r.Settings(), nil)
}
func wrap(value, min, max int) int {
if value < min {
return max

View file

@ -7,15 +7,19 @@ import (
"github.com/stretchr/testify/require"
)
func getWrappedRuleset(settings Settings) Ruleset {
return NewRulesetBuilder().WithSettings(settings).NamedRuleset(GameTypeWrapped)
}
func TestLeft(t *testing.T) {
boardState := &BoardState{
Width: 11,
Height: 11,
Snakes: []Snake{
{ID: "bottomLeft", Health: 10, Body: []Point{{0, 0}}},
{ID: "bottomRight", Health: 10, Body: []Point{{10, 0}}},
{ID: "topLeft", Health: 10, Body: []Point{{0, 10}}},
{ID: "topRight", Health: 10, Body: []Point{{10, 10}}},
{ID: "bottomLeft", Health: 10, Body: []Point{{X: 0, Y: 0}}},
{ID: "bottomRight", Health: 10, Body: []Point{{X: 10, Y: 0}}},
{ID: "topLeft", Health: 10, Body: []Point{{X: 0, Y: 10}}},
{ID: "topRight", Health: 10, Body: []Point{{X: 10, Y: 10}}},
},
}
@ -26,17 +30,18 @@ func TestLeft(t *testing.T) {
{ID: "topRight", Move: "left"},
}
r := WrappedRuleset{}
r := getWrappedRuleset(Settings{})
nextBoardState, err := r.CreateNextBoardState(boardState, snakeMoves)
gameOver, nextBoardState, err := r.Execute(boardState, snakeMoves)
require.NoError(t, err)
require.False(t, gameOver)
require.Equal(t, len(boardState.Snakes), len(nextBoardState.Snakes))
expectedSnakes := []Snake{
{ID: "bottomLeft", Health: 10, Body: []Point{{10, 0}}},
{ID: "bottomRight", Health: 10, Body: []Point{{9, 0}}},
{ID: "topLeft", Health: 10, Body: []Point{{10, 10}}},
{ID: "topRight", Health: 10, Body: []Point{{9, 10}}},
{ID: "bottomLeft", Health: 10, Body: []Point{{X: 10, Y: 0}}},
{ID: "bottomRight", Health: 10, Body: []Point{{X: 9, Y: 0}}},
{ID: "topLeft", Health: 10, Body: []Point{{X: 10, Y: 10}}},
{ID: "topRight", Health: 10, Body: []Point{{X: 9, Y: 10}}},
}
for i, snake := range nextBoardState.Snakes {
require.Equal(t, expectedSnakes[i].ID, snake.ID, snake.ID)
@ -51,10 +56,10 @@ func TestRight(t *testing.T) {
Width: 11,
Height: 11,
Snakes: []Snake{
{ID: "bottomLeft", Health: 10, Body: []Point{{0, 0}}},
{ID: "bottomRight", Health: 10, Body: []Point{{10, 0}}},
{ID: "topLeft", Health: 10, Body: []Point{{0, 10}}},
{ID: "topRight", Health: 10, Body: []Point{{10, 10}}},
{ID: "bottomLeft", Health: 10, Body: []Point{{X: 0, Y: 0}}},
{ID: "bottomRight", Health: 10, Body: []Point{{X: 10, Y: 0}}},
{ID: "topLeft", Health: 10, Body: []Point{{X: 0, Y: 10}}},
{ID: "topRight", Health: 10, Body: []Point{{X: 10, Y: 10}}},
},
}
@ -65,17 +70,18 @@ func TestRight(t *testing.T) {
{ID: "topRight", Move: "right"},
}
r := WrappedRuleset{}
r := getWrappedRuleset(Settings{})
nextBoardState, err := r.CreateNextBoardState(boardState, snakeMoves)
gameOver, nextBoardState, err := r.Execute(boardState, snakeMoves)
require.NoError(t, err)
require.False(t, gameOver)
require.Equal(t, len(boardState.Snakes), len(nextBoardState.Snakes))
expectedSnakes := []Snake{
{ID: "bottomLeft", Health: 10, Body: []Point{{1, 0}}},
{ID: "bottomRight", Health: 10, Body: []Point{{0, 0}}},
{ID: "topLeft", Health: 10, Body: []Point{{1, 10}}},
{ID: "topRight", Health: 10, Body: []Point{{0, 10}}},
{ID: "bottomLeft", Health: 10, Body: []Point{{X: 1, Y: 0}}},
{ID: "bottomRight", Health: 10, Body: []Point{{X: 0, Y: 0}}},
{ID: "topLeft", Health: 10, Body: []Point{{X: 1, Y: 10}}},
{ID: "topRight", Health: 10, Body: []Point{{X: 0, Y: 10}}},
}
for i, snake := range nextBoardState.Snakes {
require.Equal(t, expectedSnakes[i].ID, snake.ID, snake.ID)
@ -90,10 +96,10 @@ func TestUp(t *testing.T) {
Width: 11,
Height: 11,
Snakes: []Snake{
{ID: "bottomLeft", Health: 10, Body: []Point{{0, 0}}},
{ID: "bottomRight", Health: 10, Body: []Point{{10, 0}}},
{ID: "topLeft", Health: 10, Body: []Point{{0, 10}}},
{ID: "topRight", Health: 10, Body: []Point{{10, 10}}},
{ID: "bottomLeft", Health: 10, Body: []Point{{X: 0, Y: 0}}},
{ID: "bottomRight", Health: 10, Body: []Point{{X: 10, Y: 0}}},
{ID: "topLeft", Health: 10, Body: []Point{{X: 0, Y: 10}}},
{ID: "topRight", Health: 10, Body: []Point{{X: 10, Y: 10}}},
},
}
@ -104,17 +110,18 @@ func TestUp(t *testing.T) {
{ID: "topRight", Move: "up"},
}
r := WrappedRuleset{}
r := getWrappedRuleset(Settings{})
nextBoardState, err := r.CreateNextBoardState(boardState, snakeMoves)
gameOver, nextBoardState, err := r.Execute(boardState, snakeMoves)
require.NoError(t, err)
require.False(t, gameOver)
require.Equal(t, len(boardState.Snakes), len(nextBoardState.Snakes))
expectedSnakes := []Snake{
{ID: "bottomLeft", Health: 10, Body: []Point{{0, 1}}},
{ID: "bottomRight", Health: 10, Body: []Point{{10, 1}}},
{ID: "topLeft", Health: 10, Body: []Point{{0, 0}}},
{ID: "topRight", Health: 10, Body: []Point{{10, 0}}},
{ID: "bottomLeft", Health: 10, Body: []Point{{X: 0, Y: 1}}},
{ID: "bottomRight", Health: 10, Body: []Point{{X: 10, Y: 1}}},
{ID: "topLeft", Health: 10, Body: []Point{{X: 0, Y: 0}}},
{ID: "topRight", Health: 10, Body: []Point{{X: 10, Y: 0}}},
}
for i, snake := range nextBoardState.Snakes {
require.Equal(t, expectedSnakes[i].ID, snake.ID, snake.ID)
@ -129,10 +136,10 @@ func TestDown(t *testing.T) {
Width: 11,
Height: 11,
Snakes: []Snake{
{ID: "bottomLeft", Health: 10, Body: []Point{{0, 0}}},
{ID: "bottomRight", Health: 10, Body: []Point{{10, 0}}},
{ID: "topLeft", Health: 10, Body: []Point{{0, 10}}},
{ID: "topRight", Health: 10, Body: []Point{{10, 10}}},
{ID: "bottomLeft", Health: 10, Body: []Point{{X: 0, Y: 0}}},
{ID: "bottomRight", Health: 10, Body: []Point{{X: 10, Y: 0}}},
{ID: "topLeft", Health: 10, Body: []Point{{X: 0, Y: 10}}},
{ID: "topRight", Health: 10, Body: []Point{{X: 10, Y: 10}}},
},
}
@ -143,17 +150,18 @@ func TestDown(t *testing.T) {
{ID: "topRight", Move: "down"},
}
r := WrappedRuleset{}
r := getWrappedRuleset(Settings{})
nextBoardState, err := r.CreateNextBoardState(boardState, snakeMoves)
gameOver, nextBoardState, err := r.Execute(boardState, snakeMoves)
require.NoError(t, err)
require.False(t, gameOver)
require.Equal(t, len(boardState.Snakes), len(nextBoardState.Snakes))
expectedSnakes := []Snake{
{ID: "bottomLeft", Health: 10, Body: []Point{{0, 10}}},
{ID: "bottomRight", Health: 10, Body: []Point{{10, 10}}},
{ID: "topLeft", Health: 10, Body: []Point{{0, 9}}},
{ID: "topRight", Health: 10, Body: []Point{{10, 9}}},
{ID: "bottomLeft", Health: 10, Body: []Point{{X: 0, Y: 10}}},
{ID: "bottomRight", Health: 10, Body: []Point{{X: 10, Y: 10}}},
{ID: "topLeft", Health: 10, Body: []Point{{X: 0, Y: 9}}},
{ID: "topRight", Health: 10, Body: []Point{{X: 10, Y: 9}}},
}
for i, snake := range nextBoardState.Snakes {
require.Equal(t, expectedSnakes[i].ID, snake.ID, snake.ID)
@ -168,14 +176,14 @@ func TestEdgeCrossingCollision(t *testing.T) {
Width: 11,
Height: 11,
Snakes: []Snake{
{ID: "left", Health: 10, Body: []Point{{0, 5}}},
{ID: "left", Health: 10, Body: []Point{{X: 0, Y: 5}}},
{ID: "rightEdge", Health: 10, Body: []Point{
{10, 1},
{10, 2},
{10, 3},
{10, 4},
{10, 5},
{10, 6},
{X: 10, Y: 1},
{X: 10, Y: 2},
{X: 10, Y: 3},
{X: 10, Y: 4},
{X: 10, Y: 5},
{X: 10, Y: 6},
}},
},
}
@ -185,21 +193,22 @@ func TestEdgeCrossingCollision(t *testing.T) {
{ID: "rightEdge", Move: "down"},
}
r := WrappedRuleset{}
r := getWrappedRuleset(Settings{})
nextBoardState, err := r.CreateNextBoardState(boardState, snakeMoves)
gameOver, nextBoardState, err := r.Execute(boardState, snakeMoves)
require.NoError(t, err)
require.False(t, gameOver)
require.Equal(t, len(boardState.Snakes), len(nextBoardState.Snakes))
expectedSnakes := []Snake{
{ID: "left", Health: 0, Body: []Point{{10, 5}}, EliminatedCause: EliminatedByCollision, EliminatedBy: "rightEdge"},
{ID: "left", Health: 0, Body: []Point{{X: 10, Y: 5}}, EliminatedCause: EliminatedByCollision, EliminatedBy: "rightEdge"},
{ID: "rightEdge", Health: 10, Body: []Point{
{10, 0},
{10, 1},
{10, 2},
{10, 3},
{10, 4},
{10, 5},
{X: 10, Y: 0},
{X: 10, Y: 1},
{X: 10, Y: 2},
{X: 10, Y: 3},
{X: 10, Y: 4},
{X: 10, Y: 5},
}},
}
for i, snake := range nextBoardState.Snakes {
@ -215,11 +224,11 @@ func TestEdgeCrossingEating(t *testing.T) {
Width: 11,
Height: 11,
Snakes: []Snake{
{ID: "left", Health: 10, Body: []Point{{0, 5}, {1, 5}}},
{ID: "other", Health: 10, Body: []Point{{5, 5}}},
{ID: "left", Health: 10, Body: []Point{{X: 0, Y: 5}, {X: 1, Y: 5}}},
{ID: "other", Health: 10, Body: []Point{{X: 5, Y: 5}}},
},
Food: []Point{
{10, 5},
{X: 10, Y: 5},
},
}
@ -228,15 +237,16 @@ func TestEdgeCrossingEating(t *testing.T) {
{ID: "other", Move: "left"},
}
r := WrappedRuleset{}
r := getWrappedRuleset(Settings{})
nextBoardState, err := r.CreateNextBoardState(boardState, snakeMoves)
gameOver, nextBoardState, err := r.Execute(boardState, snakeMoves)
require.NoError(t, err)
require.False(t, gameOver)
require.Equal(t, len(boardState.Snakes), len(nextBoardState.Snakes))
expectedSnakes := []Snake{
{ID: "left", Health: 100, Body: []Point{{10, 5}, {0, 5}, {0, 5}}},
{ID: "other", Health: 9, Body: []Point{{4, 5}}},
{ID: "left", Health: 100, Body: []Point{{X: 10, Y: 5}, {X: 0, Y: 5}, {X: 0, Y: 5}}},
{ID: "other", Health: 9, Body: []Point{{X: 4, Y: 5}}},
}
for i, snake := range nextBoardState.Snakes {
require.Equal(t, expectedSnakes[i].ID, snake.ID, snake.ID)
@ -271,12 +281,12 @@ var wrappedCaseMoveAndWrap = gameTestCase{
Snakes: []Snake{
{
ID: "one",
Body: []Point{{0, 0}, {1, 0}},
Body: []Point{{X: 0, Y: 0}, {X: 1, Y: 0}},
Health: 100,
},
{
ID: "two",
Body: []Point{{3, 4}, {3, 3}},
Body: []Point{{X: 3, Y: 4}, {X: 3, Y: 3}},
Health: 100,
},
{
@ -301,12 +311,12 @@ var wrappedCaseMoveAndWrap = gameTestCase{
Snakes: []Snake{
{
ID: "one",
Body: []Point{{9, 0}, {0, 0}},
Body: []Point{{X: 9, Y: 0}, {X: 0, Y: 0}},
Health: 99,
},
{
ID: "two",
Body: []Point{{3, 5}, {3, 4}},
Body: []Point{{X: 3, Y: 5}, {X: 3, Y: 4}},
Health: 99,
},
{
@ -330,14 +340,10 @@ func TestWrappedCreateNextBoardState(t *testing.T) {
standardMoveAndCollideMAD,
wrappedCaseMoveAndWrap,
}
r := WrappedRuleset{}
rb := NewRulesetBuilder().WithParams(map[string]string{
ParamGameType: GameTypeWrapped,
})
r := getWrappedRuleset(Settings{})
for _, gc := range cases {
gc.requireValidNextState(t, &r)
// also test a RulesBuilder constructed instance
gc.requireValidNextState(t, rb.Ruleset())
// test a RulesBuilder constructed instance
gc.requireValidNextState(t, r)
// also test a pipeline with the same settings
gc.requireValidNextState(t, NewRulesetBuilder().PipelineRuleset(GameTypeWrapped, NewPipeline(wrappedRulesetStages...)))
}