From aa38bcd0eb1918b1b20b80c25e1fa85ab4fff86e Mon Sep 17 00:00:00 2001 From: Rob O'Dwyer Date: Tue, 31 May 2022 07:29:34 -0700 Subject: [PATCH] DEV 1283: Arcade maze map (#77) * add "namcap" map * adjust hazards and starting food positions * add food randomly, not on top of hazards * add exits on the top and bottom * rename to arcade_maze * add maps README * test for ArcadeMazeMap * adjustments to hazards in arcade_maze --- maps/README.md | 40 ++++++ maps/arcade_maze.go | 297 +++++++++++++++++++++++++++++++++++++++ maps/arcade_maze_test.go | 67 +++++++++ 3 files changed, 404 insertions(+) create mode 100644 maps/README.md create mode 100644 maps/arcade_maze.go create mode 100644 maps/arcade_maze_test.go diff --git a/maps/README.md b/maps/README.md new file mode 100644 index 0000000..e8ecd89 --- /dev/null +++ b/maps/README.md @@ -0,0 +1,40 @@ +# Game Maps + +Game maps are a way to customize the game board independently of the pipeline of game rules, including snake positions, food and hazard spawning. More advanced mechanics, such as snake bots generated by the map, may be available in the future. + +Anyone can write a new game map and submit a PR! Currently there are a few additional changes needed behind the scenes for it to appear in the production Battlesnake engine and on play.battlesnake.com, but you'll be able to use your own map right away with the [battlesnake CLI](../cli/README.md). + +## How to write a map + +You'll need to create a new Go type that implements [`maps.GameMap`](game_map.go), and register it under a unique identifier. The methods your game map will need to implement are: + +### `ID` + +Returns the unique ID this game map will be registered under. This method exists mostly to guard against typos while registering maps. + +### `Meta` +Returns some optional metadata about the map, currently name, author, and description. At some point we hope to expose this through the UI to give credit to community map authors. + +### `SetupBoard` +Called to generate a new board. The map is responsible for placing all snakes, food, and hazards. +An initial `rules.BoardState` is passed in that will be initialized with the width, height, and snakes with IDs but no bodies. `SetupBoard` should not modify this BoardState, but instead call methods on the `maps.Editor` to place snakes and optionally food/hazards. + +### `UpdateBoard` +Called to update an existing board every turn. For a map that doesn't spawn food or hazards after initial creation, this method can be a no-op! For maps that just do standard random food spawning, delegating to one of the existing maps is a good way to handle that. + +## Registering your map +Your map will need to be registered with its own ID using `maps.RegisterMap`. There are a few automated tests that will be run automatically on any registered map to ensure it appears to work correctly. You can run those tests yourself with: +``` +go test ./maps +``` + +## Things to watch out for +- `SetupBoard` is called before any turns are run and before the game rules are applied. `UpdateBoard` is called at the *end* of each turn, after snakes have moved, been eliminated, etc. +- There's no protection against placing duplicate food/hazards on the same location on the board. Maps need to account for this, especially when generating random food/hazard spawns. +- All maps that make use of random behaviour should use the `GetRand` method on the settings object passed in to get a random number generator seeded with the game's seed and current turn. This will ensure the map generates in a reliable way, and will allow reproducing games based on the seed at some point in the near future. + +## How to test your map +- You can trigger a game locally using the new map with: +``` +battlesnake play --width 11 --height 11 --name mysnake --url http://example.com/snake --name othersnake --url http://example.com/snake --map MAP_ID --viewmap +``` \ No newline at end of file diff --git a/maps/arcade_maze.go b/maps/arcade_maze.go new file mode 100644 index 0000000..51e39c3 --- /dev/null +++ b/maps/arcade_maze.go @@ -0,0 +1,297 @@ +package maps + +import ( + "github.com/BattlesnakeOfficial/rules" +) + +type ArcadeMazeMap struct{} + +func init() { + globalRegistry.RegisterMap("arcade_maze", ArcadeMazeMap{}) +} + +func (m ArcadeMazeMap) ID() string { + return "arcade_maze" +} + +func (m ArcadeMazeMap) Meta() Metadata { + return Metadata{ + Name: "Arcade Maze", + Description: "Generic arcade maze map with hazard walls", + Author: "Battlesnake", + } +} + +func (m ArcadeMazeMap) SetupBoard(initialBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + rand := settings.GetRand(0) + + if initialBoardState.Width != 19 || initialBoardState.Height != 21 { + return rules.RulesetError("This map can only be played on a 19X21 board") + } + + snakePositions := []rules.Point{ + {X: 4, Y: 7}, + {X: 14, Y: 7}, + {X: 4, Y: 17}, + {X: 14, Y: 17}, + } + + if len(initialBoardState.Snakes) > len(snakePositions) { + return rules.ErrorTooManySnakes + } + + rand.Shuffle(len(snakePositions), func(i int, j int) { + snakePositions[i], snakePositions[j] = snakePositions[j], snakePositions[i] + }) + + for index, snake := range initialBoardState.Snakes { + head := snakePositions[index] + editor.PlaceSnake(snake.ID, []rules.Point{head, head, head}, snake.Health) + } + + for _, hazard := range ArcadeMazeHazards { + editor.AddHazard(hazard) + } + + // Add food in center + editor.AddFood(rules.Point{X: 9, Y: 11}) + + return nil +} + +func (m ArcadeMazeMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + rand := settings.GetRand(lastBoardState.Turn) + + // Respect FoodSpawnChance setting + if rand.Intn(100) > settings.FoodSpawnChance { + return nil + } + + foodPositions := []rules.Point{ + {X: 3, Y: 11}, + {X: 9, Y: 11}, + {X: 15, Y: 11}, + } + + rand.Shuffle(len(foodPositions), func(i int, j int) { + foodPositions[i], foodPositions[j] = foodPositions[j], foodPositions[i] + }) + + for _, food := range foodPositions { + tileIsOccupied := false + + snakeLoop: + for _, snake := range lastBoardState.Snakes { + for _, point := range snake.Body { + if point.X == food.X && point.Y == food.Y { + tileIsOccupied = true + break snakeLoop + } + } + } + + for _, existingFood := range lastBoardState.Food { + if existingFood.X == food.X && existingFood.Y == food.Y { + tileIsOccupied = true + break + } + } + + if !tileIsOccupied { + editor.AddFood(food) + break + } + } + + return nil +} + +var ArcadeMazeHazards []rules.Point = []rules.Point{ + {X: 0, Y: 20}, + {X: 2, Y: 20}, + {X: 3, Y: 20}, + {X: 4, Y: 20}, + {X: 5, Y: 20}, + {X: 6, Y: 20}, + {X: 7, Y: 20}, + {X: 8, Y: 20}, + {X: 9, Y: 20}, + {X: 10, Y: 20}, + {X: 11, Y: 20}, + {X: 12, Y: 20}, + {X: 13, Y: 20}, + {X: 14, Y: 20}, + {X: 15, Y: 20}, + {X: 16, Y: 20}, + {X: 18, Y: 20}, + {X: 0, Y: 19}, + {X: 9, Y: 19}, + {X: 18, Y: 19}, + {X: 0, Y: 18}, + {X: 2, Y: 18}, + {X: 3, Y: 18}, + {X: 5, Y: 18}, + {X: 6, Y: 18}, + {X: 7, Y: 18}, + {X: 9, Y: 18}, + {X: 11, Y: 18}, + {X: 12, Y: 18}, + {X: 13, Y: 18}, + {X: 15, Y: 18}, + {X: 16, Y: 18}, + {X: 18, Y: 18}, + {X: 0, Y: 17}, + {X: 18, Y: 17}, + {X: 0, Y: 16}, + {X: 2, Y: 16}, + {X: 3, Y: 16}, + {X: 5, Y: 16}, + {X: 7, Y: 16}, + {X: 8, Y: 16}, + {X: 9, Y: 16}, + {X: 10, Y: 16}, + {X: 11, Y: 16}, + {X: 13, Y: 16}, + {X: 15, Y: 16}, + {X: 16, Y: 16}, + {X: 18, Y: 16}, + {X: 0, Y: 15}, + {X: 5, Y: 15}, + {X: 9, Y: 15}, + {X: 13, Y: 15}, + {X: 18, Y: 15}, + {X: 0, Y: 14}, + {X: 3, Y: 14}, + {X: 5, Y: 14}, + {X: 6, Y: 14}, + {X: 7, Y: 14}, + {X: 9, Y: 14}, + {X: 11, Y: 14}, + {X: 12, Y: 14}, + {X: 13, Y: 14}, + {X: 15, Y: 14}, + {X: 18, Y: 14}, + {X: 0, Y: 13}, + {X: 3, Y: 13}, + {X: 5, Y: 13}, + {X: 13, Y: 13}, + {X: 15, Y: 13}, + {X: 18, Y: 13}, + {X: 0, Y: 12}, + {X: 1, Y: 12}, + {X: 2, Y: 12}, + {X: 3, Y: 12}, + {X: 5, Y: 12}, + {X: 7, Y: 12}, + {X: 9, Y: 12}, + {X: 11, Y: 12}, + {X: 13, Y: 12}, + {X: 15, Y: 12}, + {X: 16, Y: 12}, + {X: 17, Y: 12}, + {X: 18, Y: 12}, + {X: 7, Y: 11}, + {X: 11, Y: 11}, + {X: 0, Y: 10}, + {X: 1, Y: 10}, + {X: 2, Y: 10}, + {X: 3, Y: 10}, + {X: 5, Y: 10}, + {X: 7, Y: 10}, + {X: 9, Y: 10}, + {X: 11, Y: 10}, + {X: 13, Y: 10}, + {X: 15, Y: 10}, + {X: 16, Y: 10}, + {X: 17, Y: 10}, + {X: 18, Y: 10}, + {X: 0, Y: 9}, + {X: 3, Y: 9}, + {X: 5, Y: 9}, + {X: 13, Y: 9}, + {X: 15, Y: 9}, + {X: 18, Y: 9}, + {X: 0, Y: 8}, + {X: 3, Y: 8}, + {X: 5, Y: 8}, + {X: 7, Y: 8}, + {X: 8, Y: 8}, + {X: 9, Y: 8}, + {X: 10, Y: 8}, + {X: 11, Y: 8}, + {X: 13, Y: 8}, + {X: 15, Y: 8}, + {X: 18, Y: 8}, + {X: 0, Y: 7}, + {X: 9, Y: 7}, + {X: 18, Y: 7}, + {X: 0, Y: 6}, + {X: 2, Y: 6}, + {X: 3, Y: 6}, + {X: 5, Y: 6}, + {X: 6, Y: 6}, + {X: 7, Y: 6}, + {X: 9, Y: 6}, + {X: 11, Y: 6}, + {X: 12, Y: 6}, + {X: 13, Y: 6}, + {X: 15, Y: 6}, + {X: 16, Y: 6}, + {X: 18, Y: 6}, + {X: 0, Y: 5}, + {X: 3, Y: 5}, + {X: 15, Y: 5}, + {X: 18, Y: 5}, + {X: 0, Y: 4}, + {X: 1, Y: 4}, + {X: 3, Y: 4}, + {X: 5, Y: 4}, + {X: 7, Y: 4}, + {X: 8, Y: 4}, + {X: 9, Y: 4}, + {X: 10, Y: 4}, + {X: 11, Y: 4}, + {X: 13, Y: 4}, + {X: 15, Y: 4}, + {X: 17, Y: 4}, + {X: 18, Y: 4}, + {X: 0, Y: 3}, + {X: 5, Y: 3}, + {X: 9, Y: 3}, + {X: 13, Y: 3}, + {X: 18, Y: 3}, + {X: 0, Y: 2}, + {X: 2, Y: 2}, + {X: 3, Y: 2}, + {X: 4, Y: 2}, + {X: 5, Y: 2}, + {X: 6, Y: 2}, + {X: 7, Y: 2}, + {X: 9, Y: 2}, + {X: 11, Y: 2}, + {X: 12, Y: 2}, + {X: 13, Y: 2}, + {X: 14, Y: 2}, + {X: 15, Y: 2}, + {X: 16, Y: 2}, + {X: 18, Y: 2}, + {X: 0, Y: 1}, + {X: 18, Y: 1}, + {X: 0, 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}, + {X: 10, Y: 0}, + {X: 11, Y: 0}, + {X: 12, Y: 0}, + {X: 13, Y: 0}, + {X: 14, Y: 0}, + {X: 15, Y: 0}, + {X: 16, Y: 0}, + {X: 18, Y: 0}, +} diff --git a/maps/arcade_maze_test.go b/maps/arcade_maze_test.go new file mode 100644 index 0000000..b023d04 --- /dev/null +++ b/maps/arcade_maze_test.go @@ -0,0 +1,67 @@ +package maps_test + +import ( + "testing" + + "github.com/BattlesnakeOfficial/rules" + "github.com/BattlesnakeOfficial/rules/maps" + "github.com/stretchr/testify/require" +) + +func TestArcadeMazeMap(t *testing.T) { + tests := []struct { + boardWidth int + boardHeight int + expectedError error + expectedHazards []rules.Point + }{ + { + boardWidth: 19, + boardHeight: 21, + expectedError: nil, + expectedHazards: maps.ArcadeMazeHazards, + }, + { + boardWidth: 18, + boardHeight: 21, + expectedError: rules.RulesetError("This map can only be played on a 19X21 board"), + expectedHazards: nil, + }, + { + boardWidth: 20, + boardHeight: 21, + expectedError: rules.RulesetError("This map can only be played on a 19X21 board"), + expectedHazards: nil, + }, + { + boardWidth: 19, + boardHeight: 20, + expectedError: rules.RulesetError("This map can only be played on a 19X21 board"), + expectedHazards: nil, + }, + { + boardWidth: 19, + boardHeight: 22, + expectedError: rules.RulesetError("This map can only be played on a 19X21 board"), + expectedHazards: nil, + }, + } + for _, test := range tests { + m := maps.ArcadeMazeMap{} + boardState := rules.NewBoardState(test.boardWidth, test.boardHeight) + settings := rules.Settings{} + editor := maps.NewBoardStateEditor(boardState) + + err := m.SetupBoard(boardState, settings, editor) + if test.expectedError != nil { + require.Equal(t, test.expectedError, err) + } else { + require.NoError(t, err) + require.Equal(t, test.expectedHazards, boardState.Hazards) + + for _, snake := range boardState.Snakes { + require.Equal(t, 3, len(snake.Body)) + } + } + } +}