From e94d758a9b6169df3f0885e1cc470f0c1be2163d Mon Sep 17 00:00:00 2001 From: Rob O'Dwyer Date: Tue, 17 May 2022 15:45:56 -0700 Subject: [PATCH] DEV 1303: Add empty and royale maps and update game map interface (#72) * move random generator into Settings * add empty and royale maps * place snakes on either cardinal or corner positions first --- board.go | 35 +++++++-- board_test.go | 58 +++++++++++++++ maps/empty.go | 49 +++++++++++++ maps/empty_test.go | 164 ++++++++++++++++++++++++++++++++++++++++++ maps/game_map.go | 9 +-- maps/helpers.go | 4 +- maps/royale.go | 74 +++++++++++++++++++ maps/standard.go | 15 ++-- maps/standard_test.go | 48 +++++++------ rand.go | 20 ++++++ ruleset.go | 32 +++++++-- ruleset_test.go | 23 ++++++ 12 files changed, 479 insertions(+), 52 deletions(-) create mode 100644 maps/empty.go create mode 100644 maps/empty_test.go create mode 100644 maps/royale.go diff --git a/board.go b/board.go index 22a3faa..7b5bf3a 100644 --- a/board.go +++ b/board.go @@ -1,5 +1,7 @@ package rules +import "fmt" + type BoardState struct { Turn int32 Height int32 @@ -14,6 +16,11 @@ type Point struct { Y int32 } +// Makes it easier to copy sample points out of Go logs and test failures. +func (p Point) GoString() string { + return fmt.Sprintf("{X:%d, Y:%d}", p.X, p.Y) +} + type Snake struct { ID string Body []Point @@ -95,26 +102,40 @@ func PlaceSnakesFixed(rand Rand, b *BoardState, snakeIDs []string) error { // Create start 8 points mn, md, mx := int32(1), (b.Width-1)/2, b.Width-2 - startPoints := []Point{ + cornerPoints := []Point{ {mn, mn}, - {mn, md}, {mn, mx}, + {mx, mn}, + {mx, mx}, + } + cardinalPoints := []Point{ + {mn, md}, {md, mn}, {md, mx}, - {mx, mn}, {mx, md}, - {mx, mx}, } // Sanity check - if len(b.Snakes) > len(startPoints) { + if len(b.Snakes) > (len(cornerPoints) + len(cardinalPoints)) { return ErrorTooManySnakes } // Randomly order them - rand.Shuffle(len(startPoints), func(i int, j int) { - startPoints[i], startPoints[j] = startPoints[j], startPoints[i] + rand.Shuffle(len(cornerPoints), func(i int, j int) { + cornerPoints[i], cornerPoints[j] = cornerPoints[j], cornerPoints[i] }) + rand.Shuffle(len(cardinalPoints), func(i int, j int) { + cardinalPoints[i], cardinalPoints[j] = cardinalPoints[j], cardinalPoints[i] + }) + + var startPoints []Point + if rand.Intn(2) == 0 { + startPoints = append(startPoints, cornerPoints...) + startPoints = append(startPoints, cardinalPoints...) + } else { + startPoints = append(startPoints, cardinalPoints...) + startPoints = append(startPoints, cornerPoints...) + } // Assign to snakes in order given for i := 0; i < len(b.Snakes); i++ { diff --git a/board_test.go b/board_test.go index e120742..68a62ad 100644 --- a/board_test.go +++ b/board_test.go @@ -225,6 +225,64 @@ func TestPlaceSnakesDefault(t *testing.T) { } } +func TestPlaceSnakesFixed(t *testing.T) { + snakeIDs := make([]string, 8) + + for _, test := range []struct { + label string + rand Rand + expectedSnakeHeads []Point + }{ + { + label: "corners before cardinal directions", + rand: MinRand, + expectedSnakeHeads: []Point{ + {X: 1, Y: 1}, + {X: 1, Y: 9}, + {X: 9, Y: 1}, + {X: 9, Y: 9}, + + {X: 1, Y: 5}, + {X: 5, Y: 1}, + {X: 5, Y: 9}, + {X: 9, Y: 5}, + }, + }, + { + label: "cardinal directions before corners", + rand: MaxRand, + expectedSnakeHeads: []Point{ + {X: 5, Y: 1}, + {X: 5, Y: 9}, + {X: 9, Y: 5}, + {X: 1, Y: 5}, + + {X: 1, Y: 9}, + {X: 9, Y: 1}, + {X: 9, Y: 9}, + {X: 1, Y: 1}, + }, + }, + } { + t.Run(test.label, func(t *testing.T) { + boardState := &BoardState{ + Width: BoardSizeMedium, + Height: BoardSizeMedium, + } + + err := PlaceSnakesAutomatically(test.rand, boardState, snakeIDs) + require.NoError(t, err) + + var snakeHeads []Point + for _, snake := range boardState.Snakes { + require.Len(t, snake.Body, 3) + snakeHeads = append(snakeHeads, snake.Body[0]) + } + require.Equalf(t, test.expectedSnakeHeads, snakeHeads, "%#v", snakeHeads) + }) + } +} + func TestPlaceSnake(t *testing.T) { // TODO: Should PlaceSnake check for boundaries? boardState := NewBoardState(BoardSizeSmall, BoardSizeSmall) diff --git a/maps/empty.go b/maps/empty.go new file mode 100644 index 0000000..ddd7ea1 --- /dev/null +++ b/maps/empty.go @@ -0,0 +1,49 @@ +package maps + +import ( + "github.com/BattlesnakeOfficial/rules" +) + +type EmptyMap struct{} + +func init() { + globalRegistry.RegisterMap("empty", EmptyMap{}) +} + +func (m EmptyMap) ID() string { + return "empty" +} + +func (m EmptyMap) Meta() Metadata { + return Metadata{ + Name: "Empty", + Description: "Default snake placement with no food", + Author: "Battlesnake", + } +} + +func (m EmptyMap) SetupBoard(initialBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + rand := settings.GetRand(0) + + snakeIDs := make([]string, 0, len(initialBoardState.Snakes)) + for _, snake := range initialBoardState.Snakes { + snakeIDs = append(snakeIDs, snake.ID) + } + + tempBoardState := rules.NewBoardState(initialBoardState.Width, initialBoardState.Height) + err := rules.PlaceSnakesAutomatically(rand, tempBoardState, snakeIDs) + if err != nil { + return err + } + + // Copy snakes from temp board state + for _, snake := range tempBoardState.Snakes { + editor.PlaceSnake(snake.ID, snake.Body, snake.Health) + } + + return nil +} + +func (m EmptyMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + return nil +} diff --git a/maps/empty_test.go b/maps/empty_test.go new file mode 100644 index 0000000..f9ebea8 --- /dev/null +++ b/maps/empty_test.go @@ -0,0 +1,164 @@ +package maps + +import ( + "testing" + + "github.com/BattlesnakeOfficial/rules" + "github.com/stretchr/testify/require" +) + +func TestEmptyMapInterface(t *testing.T) { + var _ GameMap = EmptyMap{} +} + +func TestEmptyMapSetupBoard(t *testing.T) { + m := EmptyMap{} + settings := rules.Settings{} + + tests := []struct { + name string + initialBoardState *rules.BoardState + rand rules.Rand + + expected *rules.BoardState + err error + }{ + { + "empty 7x7", + rules.NewBoardState(7, 7), + rules.MinRand, + &rules.BoardState{ + Width: 7, + Height: 7, + Snakes: []rules.Snake{}, + Food: []rules.Point{}, + Hazards: []rules.Point{}, + }, + nil, + }, + { + "not enough room for snakes 7x7", + &rules.BoardState{ + Width: 7, + Height: 7, + Snakes: generateSnakes(9), + Food: []rules.Point{}, + Hazards: []rules.Point{}, + }, + rules.MinRand, + nil, + rules.ErrorTooManySnakes, + }, + { + "not enough room for snakes 5x5", + &rules.BoardState{ + Width: 5, + Height: 5, + Snakes: generateSnakes(14), + Food: []rules.Point{}, + Hazards: []rules.Point{}, + }, + rules.MinRand, + nil, + rules.ErrorNoRoomForSnake, + }, + { + "full 11x11 min", + &rules.BoardState{ + Width: 11, + Height: 11, + Snakes: generateSnakes(8), + Food: []rules.Point{}, + Hazards: []rules.Point{}, + }, + rules.MinRand, + &rules.BoardState{ + Width: 11, + Height: 11, + Snakes: []rules.Snake{ + {ID: "1", Body: []rules.Point{{X: 1, Y: 1}, {X: 1, Y: 1}, {X: 1, Y: 1}}, Health: 100}, + {ID: "2", Body: []rules.Point{{X: 1, Y: 9}, {X: 1, Y: 9}, {X: 1, Y: 9}}, Health: 100}, + {ID: "3", Body: []rules.Point{{X: 9, Y: 1}, {X: 9, Y: 1}, {X: 9, Y: 1}}, Health: 100}, + {ID: "4", Body: []rules.Point{{X: 9, Y: 9}, {X: 9, Y: 9}, {X: 9, Y: 9}}, Health: 100}, + {ID: "5", Body: []rules.Point{{X: 1, Y: 5}, {X: 1, Y: 5}, {X: 1, Y: 5}}, Health: 100}, + {ID: "6", Body: []rules.Point{{X: 5, Y: 1}, {X: 5, Y: 1}, {X: 5, Y: 1}}, Health: 100}, + {ID: "7", Body: []rules.Point{{X: 5, Y: 9}, {X: 5, Y: 9}, {X: 5, Y: 9}}, Health: 100}, + {ID: "8", Body: []rules.Point{{X: 9, Y: 5}, {X: 9, Y: 5}, {X: 9, Y: 5}}, Health: 100}, + }, + Food: []rules.Point{}, + Hazards: []rules.Point{}, + }, + nil, + }, + { + "full 11x11 max", + &rules.BoardState{ + Width: 11, + Height: 11, + Snakes: generateSnakes(8), + Food: []rules.Point{}, + Hazards: []rules.Point{}, + }, + rules.MaxRand, + &rules.BoardState{ + Width: 11, + Height: 11, + Snakes: []rules.Snake{ + {ID: "1", Body: []rules.Point{{X: 5, Y: 1}, {X: 5, Y: 1}, {X: 5, Y: 1}}, Health: 100}, + {ID: "2", Body: []rules.Point{{X: 5, Y: 9}, {X: 5, Y: 9}, {X: 5, Y: 9}}, Health: 100}, + {ID: "3", Body: []rules.Point{{X: 9, Y: 5}, {X: 9, Y: 5}, {X: 9, Y: 5}}, Health: 100}, + {ID: "4", Body: []rules.Point{{X: 1, Y: 5}, {X: 1, Y: 5}, {X: 1, Y: 5}}, Health: 100}, + {ID: "5", Body: []rules.Point{{X: 1, Y: 9}, {X: 1, Y: 9}, {X: 1, Y: 9}}, Health: 100}, + {ID: "6", Body: []rules.Point{{X: 9, Y: 1}, {X: 9, Y: 1}, {X: 9, Y: 1}}, Health: 100}, + {ID: "7", Body: []rules.Point{{X: 9, Y: 9}, {X: 9, Y: 9}, {X: 9, Y: 9}}, Health: 100}, + {ID: "8", Body: []rules.Point{{X: 1, Y: 1}, {X: 1, Y: 1}, {X: 1, Y: 1}}, Health: 100}, + }, + Food: []rules.Point{}, + Hazards: []rules.Point{}, + }, + nil, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + nextBoardState := rules.NewBoardState(test.initialBoardState.Width, test.initialBoardState.Height) + editor := NewBoardStateEditor(nextBoardState) + settings := settings.WithRand(test.rand) + + err := m.SetupBoard(test.initialBoardState, settings, editor) + + if test.err != nil { + require.Equal(t, test.err, err) + } else { + require.Equal(t, test.expected, nextBoardState) + } + }) + } +} + +func TestEmptyMapUpdateBoard(t *testing.T) { + m := EmptyMap{} + initialBoardState := &rules.BoardState{ + Width: 2, + Height: 2, + Snakes: []rules.Snake{}, + Food: []rules.Point{{X: 0, Y: 0}}, + Hazards: []rules.Point{}, + } + settings := rules.Settings{ + FoodSpawnChance: 50, + MinimumFood: 2, + }.WithRand(rules.MaxRand) + nextBoardState := initialBoardState.Clone() + + err := m.UpdateBoard(initialBoardState.Clone(), settings, NewBoardStateEditor(nextBoardState)) + + require.NoError(t, err) + require.Equal(t, &rules.BoardState{ + Width: 2, + Height: 2, + Snakes: []rules.Snake{}, + Food: []rules.Point{{X: 0, Y: 0}}, + Hazards: []rules.Point{}, + }, nextBoardState) +} diff --git a/maps/game_map.go b/maps/game_map.go index 5c80427..b765e48 100644 --- a/maps/game_map.go +++ b/maps/game_map.go @@ -24,9 +24,6 @@ type Metadata struct { // Editor is used by GameMap implementations to modify the board state. type Editor interface { - // Returns a random number generator. This MUST be used for any non-deterministic behavior in a GameMap. - Random() rules.Rand - // Clears all food from the board. ClearFood() @@ -52,18 +49,14 @@ type Editor interface { // An Editor backed by a BoardState. type BoardStateEditor struct { *rules.BoardState - rand rules.Rand } -func NewBoardStateEditor(boardState *rules.BoardState, rand rules.Rand) *BoardStateEditor { +func NewBoardStateEditor(boardState *rules.BoardState) *BoardStateEditor { return &BoardStateEditor{ BoardState: boardState, - rand: rand, } } -func (editor *BoardStateEditor) Random() rules.Rand { return editor.rand } - func (editor *BoardStateEditor) ClearFood() { editor.Food = []rules.Point{} } diff --git a/maps/helpers.go b/maps/helpers.go index 14ac135..44ed4a6 100644 --- a/maps/helpers.go +++ b/maps/helpers.go @@ -13,7 +13,7 @@ func SetupBoard(mapID string, settings rules.Settings, width, height int, snakeI return nil, err } - editor := NewBoardStateEditor(boardState, settings.Rand()) + editor := NewBoardStateEditor(boardState) err = gameMap.SetupBoard(boardState, settings, editor) if err != nil { @@ -31,7 +31,7 @@ func UpdateBoard(mapID string, previousBoardState *rules.BoardState, settings ru } nextBoardState := previousBoardState.Clone() - editor := NewBoardStateEditor(nextBoardState, settings.Rand()) + editor := NewBoardStateEditor(nextBoardState) err = gameMap.UpdateBoard(previousBoardState, settings, editor) if err != nil { diff --git a/maps/royale.go b/maps/royale.go new file mode 100644 index 0000000..b42636e --- /dev/null +++ b/maps/royale.go @@ -0,0 +1,74 @@ +package maps + +import ( + "errors" + + "github.com/BattlesnakeOfficial/rules" +) + +type RoyaleHazardsMap struct{} + +func init() { + globalRegistry.RegisterMap("royale", RoyaleHazardsMap{}) +} + +func (m RoyaleHazardsMap) ID() string { + return "royale" +} + +func (m RoyaleHazardsMap) Meta() Metadata { + return Metadata{ + Name: "Royale", + Description: "A map where hazards are generated every N turns", + Author: "Battlesnake", + } +} + +func (m RoyaleHazardsMap) SetupBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + return StandardMap{}.SetupBoard(lastBoardState, settings, editor) +} + +func (m RoyaleHazardsMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + // Royale uses the current turn to generate hazards, not the previous turn that's in the board state + turn := lastBoardState.Turn + 1 + + if settings.RoyaleSettings.ShrinkEveryNTurns < 1 { + return errors.New("royale game can't shrink more frequently than every turn") + } + + if turn < settings.RoyaleSettings.ShrinkEveryNTurns { + return nil + } + + // Reset hazards every turn and re-generate them + editor.ClearHazards() + + // Get random generator for turn zero, because we're regenerating all hazards every time. + randGenerator := settings.GetRand(0) + + numShrinks := turn / settings.RoyaleSettings.ShrinkEveryNTurns + minX, maxX := int32(0), lastBoardState.Width-1 + minY, maxY := int32(0), lastBoardState.Height-1 + for i := int32(0); i < numShrinks; i++ { + switch randGenerator.Intn(4) { + case 0: + minX += 1 + case 1: + maxX -= 1 + case 2: + minY += 1 + case 3: + maxY -= 1 + } + } + + for x := int32(0); x < lastBoardState.Width; x++ { + for y := int32(0); y < lastBoardState.Height; y++ { + if x < minX || x > maxX || y < minY || y > maxY { + editor.AddHazard(rules.Point{X: x, Y: y}) + } + } + } + + return nil +} diff --git a/maps/standard.go b/maps/standard.go index 059607d..12d6dec 100644 --- a/maps/standard.go +++ b/maps/standard.go @@ -23,12 +23,14 @@ func (m StandardMap) Meta() Metadata { } func (m StandardMap) SetupBoard(initialBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + rand := settings.GetRand(0) + snakeIDs := make([]string, 0, len(initialBoardState.Snakes)) for _, snake := range initialBoardState.Snakes { snakeIDs = append(snakeIDs, snake.ID) } - tempBoardState, err := rules.CreateDefaultBoardState(editor.Random(), initialBoardState.Width, initialBoardState.Height, snakeIDs) + tempBoardState, err := rules.CreateDefaultBoardState(rand, initialBoardState.Width, initialBoardState.Height, snakeIDs) if err != nil { return err } @@ -47,30 +49,31 @@ func (m StandardMap) SetupBoard(initialBoardState *rules.BoardState, settings ru } func (m StandardMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + rand := settings.GetRand(lastBoardState.Turn) minFood := int(settings.MinimumFood) foodSpawnChance := int(settings.FoodSpawnChance) numCurrentFood := len(lastBoardState.Food) if numCurrentFood < minFood { - placeFoodRandomly(lastBoardState, editor, minFood-numCurrentFood) + placeFoodRandomly(rand, lastBoardState, editor, minFood-numCurrentFood) return nil } - if foodSpawnChance > 0 && (100-editor.Random().Intn(100)) < foodSpawnChance { - placeFoodRandomly(lastBoardState, editor, 1) + if foodSpawnChance > 0 && (100-rand.Intn(100)) < foodSpawnChance { + placeFoodRandomly(rand, lastBoardState, editor, 1) return nil } return nil } -func placeFoodRandomly(b *rules.BoardState, editor Editor, n int) { +func placeFoodRandomly(rand rules.Rand, b *rules.BoardState, editor Editor, n int) { unoccupiedPoints := rules.GetUnoccupiedPoints(b, false) if len(unoccupiedPoints) < n { n = len(unoccupiedPoints) } - editor.Random().Shuffle(len(unoccupiedPoints), func(i int, j int) { + rand.Shuffle(len(unoccupiedPoints), func(i int, j int) { unoccupiedPoints[i], unoccupiedPoints[j] = unoccupiedPoints[j], unoccupiedPoints[i] }) diff --git a/maps/standard_test.go b/maps/standard_test.go index d80efb9..51abb1b 100644 --- a/maps/standard_test.go +++ b/maps/standard_test.go @@ -78,23 +78,23 @@ func TestStandardMapSetupBoard(t *testing.T) { Height: 11, Snakes: []rules.Snake{ {ID: "1", Body: []rules.Point{{X: 1, Y: 1}, {X: 1, Y: 1}, {X: 1, Y: 1}}, Health: 100}, - {ID: "2", Body: []rules.Point{{X: 1, Y: 5}, {X: 1, Y: 5}, {X: 1, Y: 5}}, Health: 100}, - {ID: "3", Body: []rules.Point{{X: 1, Y: 9}, {X: 1, Y: 9}, {X: 1, Y: 9}}, Health: 100}, - {ID: "4", Body: []rules.Point{{X: 5, Y: 1}, {X: 5, Y: 1}, {X: 5, Y: 1}}, Health: 100}, - {ID: "5", Body: []rules.Point{{X: 5, Y: 9}, {X: 5, Y: 9}, {X: 5, Y: 9}}, Health: 100}, - {ID: "6", Body: []rules.Point{{X: 9, Y: 1}, {X: 9, Y: 1}, {X: 9, Y: 1}}, Health: 100}, - {ID: "7", Body: []rules.Point{{X: 9, Y: 5}, {X: 9, Y: 5}, {X: 9, Y: 5}}, Health: 100}, - {ID: "8", Body: []rules.Point{{X: 9, Y: 9}, {X: 9, Y: 9}, {X: 9, Y: 9}}, Health: 100}, + {ID: "2", Body: []rules.Point{{X: 1, Y: 9}, {X: 1, Y: 9}, {X: 1, Y: 9}}, Health: 100}, + {ID: "3", Body: []rules.Point{{X: 9, Y: 1}, {X: 9, Y: 1}, {X: 9, Y: 1}}, Health: 100}, + {ID: "4", Body: []rules.Point{{X: 9, Y: 9}, {X: 9, Y: 9}, {X: 9, Y: 9}}, Health: 100}, + {ID: "5", Body: []rules.Point{{X: 1, Y: 5}, {X: 1, Y: 5}, {X: 1, Y: 5}}, Health: 100}, + {ID: "6", Body: []rules.Point{{X: 5, Y: 1}, {X: 5, Y: 1}, {X: 5, Y: 1}}, Health: 100}, + {ID: "7", Body: []rules.Point{{X: 5, Y: 9}, {X: 5, Y: 9}, {X: 5, Y: 9}}, Health: 100}, + {ID: "8", Body: []rules.Point{{X: 9, Y: 5}, {X: 9, Y: 5}, {X: 9, Y: 5}}, Health: 100}, }, Food: []rules.Point{ {X: 0, Y: 2}, - {X: 0, Y: 4}, {X: 0, Y: 8}, + {X: 8, Y: 0}, + {X: 8, Y: 10}, + {X: 0, Y: 4}, {X: 4, Y: 0}, {X: 4, Y: 10}, - {X: 8, Y: 0}, {X: 10, Y: 4}, - {X: 8, Y: 10}, {X: 5, Y: 5}, }, Hazards: []rules.Point{}, @@ -115,22 +115,22 @@ func TestStandardMapSetupBoard(t *testing.T) { Width: 11, Height: 11, Snakes: []rules.Snake{ - {ID: "1", Body: []rules.Point{{X: 1, Y: 5}, {X: 1, Y: 5}, {X: 1, Y: 5}}, Health: 100}, - {ID: "2", Body: []rules.Point{{X: 1, Y: 9}, {X: 1, Y: 9}, {X: 1, Y: 9}}, Health: 100}, - {ID: "3", Body: []rules.Point{{X: 5, Y: 1}, {X: 5, Y: 1}, {X: 5, Y: 1}}, Health: 100}, - {ID: "4", Body: []rules.Point{{X: 5, Y: 9}, {X: 5, Y: 9}, {X: 5, Y: 9}}, Health: 100}, - {ID: "5", Body: []rules.Point{{X: 9, Y: 1}, {X: 9, Y: 1}, {X: 9, Y: 1}}, Health: 100}, - {ID: "6", Body: []rules.Point{{X: 9, Y: 5}, {X: 9, Y: 5}, {X: 9, Y: 5}}, Health: 100}, + {ID: "1", Body: []rules.Point{{X: 5, Y: 1}, {X: 5, Y: 1}, {X: 5, Y: 1}}, Health: 100}, + {ID: "2", Body: []rules.Point{{X: 5, Y: 9}, {X: 5, Y: 9}, {X: 5, Y: 9}}, Health: 100}, + {ID: "3", Body: []rules.Point{{X: 9, Y: 5}, {X: 9, Y: 5}, {X: 9, Y: 5}}, Health: 100}, + {ID: "4", Body: []rules.Point{{X: 1, Y: 5}, {X: 1, Y: 5}, {X: 1, Y: 5}}, Health: 100}, + {ID: "5", Body: []rules.Point{{X: 1, Y: 9}, {X: 1, Y: 9}, {X: 1, Y: 9}}, Health: 100}, + {ID: "6", Body: []rules.Point{{X: 9, Y: 1}, {X: 9, Y: 1}, {X: 9, Y: 1}}, Health: 100}, {ID: "7", Body: []rules.Point{{X: 9, Y: 9}, {X: 9, Y: 9}, {X: 9, Y: 9}}, Health: 100}, {ID: "8", Body: []rules.Point{{X: 1, Y: 1}, {X: 1, Y: 1}, {X: 1, Y: 1}}, Health: 100}, }, Food: []rules.Point{ - {X: 0, Y: 6}, - {X: 2, Y: 10}, {X: 6, Y: 0}, {X: 6, Y: 10}, - {X: 10, Y: 2}, {X: 10, Y: 6}, + {X: 0, Y: 6}, + {X: 2, Y: 10}, + {X: 10, Y: 2}, {X: 10, Y: 8}, {X: 2, Y: 0}, {X: 5, Y: 5}, @@ -143,14 +143,15 @@ func TestStandardMapSetupBoard(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { nextBoardState := rules.NewBoardState(test.initialBoardState.Width, test.initialBoardState.Height) - editor := NewBoardStateEditor(nextBoardState, test.rand) + editor := NewBoardStateEditor(nextBoardState) + settings := settings.WithRand(test.rand) err := m.SetupBoard(test.initialBoardState, settings, editor) if test.err != nil { require.Equal(t, test.err, err) } else { - require.Equal(t, test.expected, nextBoardState) + require.Equalf(t, test.expected, nextBoardState, "%#v", nextBoardState.Food) } }) } @@ -301,9 +302,10 @@ func TestStandardMapUpdateBoard(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { nextBoardState := test.initialBoardState.Clone() - editor := NewBoardStateEditor(nextBoardState, test.rand) + settings := test.settings.WithRand(test.rand) + editor := NewBoardStateEditor(nextBoardState) - err := m.UpdateBoard(test.initialBoardState.Clone(), test.settings, editor) + err := m.UpdateBoard(test.initialBoardState.Clone(), settings, editor) require.NoError(t, err) require.Equal(t, test.expected, nextBoardState) diff --git a/rand.go b/rand.go index 9d8eb02..4febf79 100644 --- a/rand.go +++ b/rand.go @@ -20,6 +20,26 @@ func (globalRand) Shuffle(n int, swap func(i, j int)) { rand.Shuffle(n, swap) } +type seedRand struct { + seed int64 + rand *rand.Rand +} + +func NewSeedRand(seed int64) *seedRand { + return &seedRand{ + seed: seed, + rand: rand.New(rand.NewSource(seed)), + } +} + +func (s seedRand) Intn(n int) int { + return s.rand.Intn(n) +} + +func (s seedRand) Shuffle(n int, swap func(i, j int)) { + s.rand.Shuffle(n, swap) +} + // For testing purposes // A Rand implementation that always returns the minimum value for any method. diff --git a/ruleset.go b/ruleset.go index 3f28f49..39b1a74 100644 --- a/ruleset.go +++ b/ruleset.go @@ -30,14 +30,22 @@ type Settings struct { SquadSettings SquadSettings `json:"squad"` rand Rand + seed int64 } -func (settings Settings) Rand() Rand { - // Default to global random number generator if none is set. - if settings.rand == nil { - return GlobalRand +// Get a random number generator initialized based on the seed and current turn. +func (settings Settings) GetRand(turn int32) Rand { + // Allow overriding the random generator for testing + if settings.rand != nil { + return settings.rand } - return settings.rand + + if settings.seed != 0 { + return NewSeedRand(settings.seed + int64(turn+1)) + } + + // Default to global random number generator if neither seed or rand are set. + return GlobalRand } func (settings Settings) WithRand(rand Rand) Settings { @@ -45,6 +53,15 @@ func (settings Settings) WithRand(rand Rand) Settings { 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 { seed int64 @@ -92,12 +109,14 @@ func (rb *rulesetBuilder) WithParams(params map[string]string) *rulesetBuilder { return rb } -// Deprecated: WithSeed sets the seed used for randomisation by certain game modes. +// WithSeed sets the seed used for randomisation by certain game modes. func (rb *rulesetBuilder) WithSeed(seed int64) *rulesetBuilder { rb.seed = seed return rb } +// WithRandom overrides the random number generator with a specific instance +// instead of a Rand initialized from the seed. func (rb *rulesetBuilder) WithRand(rand Rand) *rulesetBuilder { rb.rand = rand return rb @@ -190,6 +209,7 @@ func (rb rulesetBuilder) PipelineRuleset(name string, p Pipeline) PipelineRulese SharedLength: paramsBool(rb.params, ParamSharedLength, false), }, rand: rb.rand, + seed: rb.seed, }, } } diff --git a/ruleset_test.go b/ruleset_test.go index b90642b..ba3d18c 100644 --- a/ruleset_test.go +++ b/ruleset_test.go @@ -191,3 +191,26 @@ func TestStageFuncContract(t *testing.T) { require.NoError(t, err) require.True(t, ended) } + +func TestRulesetBuilderGetRand(t *testing.T) { + var seed int64 = 12345 + var turn int32 = 5 + ruleset := rules.NewRulesetBuilder().WithSeed(seed).PipelineRuleset("example", rules.NewPipeline(rules.StageGameOverStandard)) + + rand1 := ruleset.Settings().GetRand(turn) + + // Should produce a predictable series of numbers based on a seed + require.Equal(t, 80, rand1.Intn(100)) + require.Equal(t, 94, rand1.Intn(100)) + + // Should produce the same number if re-initialized + require.Equal( + t, + ruleset.Settings().GetRand(turn).Intn(100), + ruleset.Settings().GetRand(turn).Intn(100), + ) + + // Should produce a different series of numbers for another turn + require.Equal(t, 22, rand1.Intn(100)) + require.Equal(t, 16, rand1.Intn(100)) +}