diff --git a/maps/registry_test.go b/maps/registry_test.go index 5602ec1..6b8b840 100644 --- a/maps/registry_test.go +++ b/maps/registry_test.go @@ -63,7 +63,9 @@ func TestRegisteredMaps(t *testing.T) { for height := 0; height < maxBoardHeight; height++ { initialBoardState := rules.NewBoardState(width, height) initialBoardState.Snakes = append(initialBoardState.Snakes, rules.Snake{ID: "1", Body: []rules.Point{}}) - initialBoardState.Snakes = append(initialBoardState.Snakes, rules.Snake{ID: "2", Body: []rules.Point{}}) + if meta.MaxPlayers > 1 { + initialBoardState.Snakes = append(initialBoardState.Snakes, rules.Snake{ID: "2", Body: []rules.Point{}}) + } passedBoardState := initialBoardState.Clone() tempBoardState := initialBoardState.Clone() err := gameMap.SetupBoard(passedBoardState, testSettings, NewBoardStateEditor(tempBoardState)) diff --git a/maps/solo_maze.go b/maps/solo_maze.go new file mode 100644 index 0000000..29f30eb --- /dev/null +++ b/maps/solo_maze.go @@ -0,0 +1,546 @@ +package maps + +import ( + "fmt" + "strconv" + + "bufio" + "bytes" + "log" + "os" + + "github.com/BattlesnakeOfficial/rules" +) + +// When this is flipped to `true` TWO things happen +// 1. More println style debugging is done +// 2. We print out the current game board in between each room sub-division, +// and wait for the CLI User to hit enter to sub-divide the next room. This +// allows you to see the maze get generated in realtime, which was super useful +// while debugging issues in the maze generation +const DEBUG_MAZE_GENERATION = false + +const INITIAL_MAZE_SIZE = 7 + +const TURNS_AT_MAX_SIZE = 5 + +const EVIL_MODE_DISTANCE_TO_FOOD = 5 + +const MAX_TRIES = 100 + +type SoloMazeMap struct{} + +func init() { + mazeMap := SoloMazeMap{} + globalRegistry.RegisterMap(mazeMap.ID(), mazeMap) +} + +func (m SoloMazeMap) ID() string { + return "solo_maze" +} + +func (m SoloMazeMap) Meta() Metadata { + return Metadata{ + Name: "Solo Maze", + Description: "Solo Maze where you need to find the food", + Author: "coreyja", + Version: 1, + MinPlayers: 1, + MaxPlayers: 1, + BoardSizes: FixedSizes( + Dimensions{7, 7}, + Dimensions{11, 11}, + Dimensions{19, 19}, + Dimensions{19, 21}, + Dimensions{25, 25}, + ), + } +} + +func (m SoloMazeMap) SetupBoard(initialBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + if len(initialBoardState.Snakes) != 1 { + return rules.RulesetError("This map requires exactly one snake") + } + + if initialBoardState.Width < INITIAL_MAZE_SIZE || initialBoardState.Height < INITIAL_MAZE_SIZE { + return rules.RulesetError( + fmt.Sprintf("This map requires a board size of at least %dx%d", INITIAL_MAZE_SIZE, INITIAL_MAZE_SIZE)) + } + + return m.CreateMaze(initialBoardState, settings, editor, 0) +} + +func maxBoardSize(boardState *rules.BoardState) int { + return min(boardState.Width, boardState.Height-2) +} + +func gameNeedsToEndSoon(maxBoardSize int, currentLevel int64) bool { + return currentLevel-TURNS_AT_MAX_SIZE > int64(maxBoardSize-INITIAL_MAZE_SIZE) +} + +func (m SoloMazeMap) CreateMaze(initialBoardState *rules.BoardState, settings rules.Settings, editor Editor, currentLevel int64) error { + rand := settings.GetRand(initialBoardState.Turn) + + // Make sure the actual maze size can always fit in the CreateBoard + // This means that when you get to 'max' size each level stops making + // the maze bigger + actualBoardSize := INITIAL_MAZE_SIZE + currentLevel + maxBoardSize := maxBoardSize(initialBoardState) + + if actualBoardSize > int64(maxBoardSize) { + actualBoardSize = int64(maxBoardSize) + } + + me := initialBoardState.Snakes[0] + + mazeBoardState := rules.NewBoardState(int(actualBoardSize), int(actualBoardSize)) + tempBoardState := initialBoardState.Clone() + + topRightCorner := rules.Point{X: int(actualBoardSize) - 1, Y: int(actualBoardSize) - 1} + + editor.ClearHazards() + + m.WriteBitState(initialBoardState, currentLevel, editor) + + m.SubdivideRoom(mazeBoardState, rand, rules.Point{X: 0, Y: 0}, topRightCorner, make([]int, 0), make([]int, 0), 0) + + for _, point := range removeDuplicateValues(mazeBoardState.Hazards) { + adjusted := m.AdjustPosition(point, int(actualBoardSize), initialBoardState.Height, initialBoardState.Width) + editor.AddHazard(adjusted) + tempBoardState.Hazards = append(tempBoardState.Hazards, adjusted) + } + + // Since we reserve the bottom row of the board for state, + // AND we center the maze within the board we know there will + // always be a `y: -1` that we can put the tail into + snake_head_position := rules.Point{X: 0, Y: 0} + snake_tail_position := rules.Point{X: 0, Y: -1} + + snakeBody := []rules.Point{ + snake_head_position, + } + for i := 0; i <= int(currentLevel)+1; i++ { + snakeBody = append(snakeBody, snake_tail_position) + } + + adjustedSnakeBody := make([]rules.Point, len(snakeBody)) + for i, point := range snakeBody { + adjustedSnakeBody[i] = m.AdjustPosition(point, int(actualBoardSize), initialBoardState.Height, initialBoardState.Width) + } + editor.PlaceSnake(me.ID, adjustedSnakeBody, 100) + tempBoardState.Snakes[0].Body = adjustedSnakeBody + + /// Pick random food spawn point + m.PlaceFood(tempBoardState, settings, editor, currentLevel) + + // Fill outside of the board with walls + xAdjust := int((initialBoardState.Width - int(actualBoardSize)) / 2) + yAdjust := int((initialBoardState.Height - int(actualBoardSize)) / 2) + for x := 0; x < initialBoardState.Width; x++ { + for y := 1; y < initialBoardState.Height; y++ { + if x < xAdjust || y < yAdjust || x >= xAdjust+int(actualBoardSize) || y >= yAdjust+int(actualBoardSize) { + editor.AddHazard(rules.Point{X: x, Y: y}) + } + } + } + + return nil +} + +func (m SoloMazeMap) PlaceFood(boardState *rules.BoardState, settings rules.Settings, editor Editor, currentLevel int64) { + actualBoardSize := INITIAL_MAZE_SIZE + currentLevel + maxBoardSize := maxBoardSize(boardState) + if actualBoardSize > int64(maxBoardSize) { + actualBoardSize = int64(maxBoardSize) + } + meBody := boardState.Snakes[0].Body + myHead := meBody[0] + + foodPlaced := false + tries := 0 + // We want to place a random food, but we also want an escape hatch for if the algo gets stuck in a loop + // trying to place a food. + for !foodPlaced && tries < MAX_TRIES { + tries++ + rand := settings.GetRand(boardState.Turn + tries) + + foodSpawnPoint := rules.Point{X: rand.Intn(int(actualBoardSize)), Y: rand.Intn(int(actualBoardSize))} + adjustedFood := m.AdjustPosition(foodSpawnPoint, int(actualBoardSize), boardState.Height, boardState.Width) + + minDistanceFromFood := min(EVIL_MODE_DISTANCE_TO_FOOD, int(actualBoardSize/2)) + if !containsPoint(boardState.Hazards, adjustedFood) && !containsPoint(meBody, adjustedFood) && manhattanDistance(adjustedFood, myHead) >= minDistanceFromFood { + editor.AddFood(adjustedFood) + foodPlaced = true + } + } +} + +func (m SoloMazeMap) UpdateBoard(lastBoardState *rules.BoardState, settings rules.Settings, editor Editor) error { + currentLevel, e := m.ReadBitState(lastBoardState) + if e != nil { + return e + } + + if len(lastBoardState.Food) == 0 { + currentLevel += 1 + m.WriteBitState(lastBoardState, currentLevel, editor) + + // This will create a new maze + return m.CreateMaze(lastBoardState, settings, editor, currentLevel) + } + + maxBoardSize := maxBoardSize(lastBoardState) + + food := lastBoardState.Food[0] + + meBody := lastBoardState.Snakes[0].Body + myHead := meBody[0] + + if gameNeedsToEndSoon(maxBoardSize, currentLevel) && manhattanDistance(myHead, food) < EVIL_MODE_DISTANCE_TO_FOOD { + editor.RemoveFood(food) + + m.PlaceFood(lastBoardState, settings, editor, currentLevel) + } + + return nil +} + +// Mostly based off this algorithm from Wikipedia: https://en.wikipedia.org/wiki/Maze_generation_algorithm#Recursive_division_method +func (m SoloMazeMap) SubdivideRoom(tempBoardState *rules.BoardState, rand rules.Rand, lowPoint rules.Point, highPoint rules.Point, disAllowedHorizontal []int, disAllowedVertical []int, depth int) bool { + didSubdivide := false + + if DEBUG_MAZE_GENERATION { + log.Print("\n\n\n") + log.Print(fmt.Sprintf("Subdividing room from %v to %v", lowPoint, highPoint)) + log.Print(fmt.Sprintf("disAllowedVertical %v", disAllowedVertical)) + log.Print(fmt.Sprintf("disAllowedHorizontal %v", disAllowedHorizontal)) + printMap(tempBoardState) + fmt.Print("Press 'Enter' to continue...") + _, e := bufio.NewReader(os.Stdin).ReadBytes('\n') + if e != nil { + log.Fatal(e) + } + } + + verticalWallPosition := -1 + horizontalWallPosition := -1 + newVerticalWall := make([]rules.Point, 0) + newHorizontalWall := make([]rules.Point, 0) + + if highPoint.X-lowPoint.X <= 2 && highPoint.Y-lowPoint.Y <= 2 { + return false + } + + verticalChoices := make([]int, 0) + for i := lowPoint.X + 1; i < highPoint.X-1; i++ { + if !contains(disAllowedVertical, i) { + verticalChoices = append(verticalChoices, i) + } + } + if len(verticalChoices) > 0 { + verticalWallPosition = verticalChoices[rand.Intn(len(verticalChoices))] + if DEBUG_MAZE_GENERATION { + log.Print(fmt.Sprintf("drawing Vertical Wall at %v", verticalWallPosition)) + } + + for y := lowPoint.Y; y <= highPoint.Y; y++ { + newVerticalWall = append(newVerticalWall, rules.Point{X: verticalWallPosition, Y: y}) + } + + didSubdivide = true + } + + /// We can only draw a horizontal wall if there is enough space + horizontalChoices := make([]int, 0) + for i := lowPoint.Y + 1; i < highPoint.Y-1; i++ { + if !contains(disAllowedHorizontal, i) { + horizontalChoices = append(horizontalChoices, i) + } + } + if len(horizontalChoices) > 0 { + horizontalWallPosition = horizontalChoices[rand.Intn(len(horizontalChoices))] + if DEBUG_MAZE_GENERATION { + log.Print(fmt.Sprintf("drawing horizontal Wall at %v", horizontalWallPosition)) + } + + for x := lowPoint.X; x <= highPoint.X; x++ { + newHorizontalWall = append(newHorizontalWall, rules.Point{X: x, Y: horizontalWallPosition}) + } + + didSubdivide = true + } + + /// Here we make cuts in the walls + if len(newVerticalWall) > 1 && len(newHorizontalWall) > 1 { + if DEBUG_MAZE_GENERATION { + log.Print("Need to cut with both walls") + } + intersectionPoint := rules.Point{X: verticalWallPosition, Y: horizontalWallPosition} + + newNewVerticalWall, verticalHoles := cutHoles(newVerticalWall, intersectionPoint, rand) + newVerticalWall = newNewVerticalWall + + for _, hole := range verticalHoles { + disAllowedHorizontal = append(disAllowedHorizontal, hole.Y) + } + if DEBUG_MAZE_GENERATION { + log.Print(fmt.Sprintf("Vertical Cuts are at %v", verticalHoles)) + } + + newNewHorizontalWall, horizontalHoles := cutHoleSingle(newHorizontalWall, intersectionPoint, rand) + newHorizontalWall = newNewHorizontalWall + for _, hole := range horizontalHoles { + disAllowedVertical = append(disAllowedVertical, hole.X) + } + if DEBUG_MAZE_GENERATION { + log.Print(fmt.Sprintf("Horizontal Cuts are at %v", horizontalHoles)) + } + } else if len(newVerticalWall) > 1 { + if DEBUG_MAZE_GENERATION { + log.Print("Only a vertical wall needs cut") + } + segmentToRemove := rand.Intn(len(newVerticalWall) - 1) + hole := newVerticalWall[segmentToRemove] + newVerticalWall = remove(newVerticalWall, segmentToRemove) + + disAllowedHorizontal = append(disAllowedHorizontal, hole.Y) + if DEBUG_MAZE_GENERATION { + log.Print(fmt.Sprintf("Cuts are at %v from index %v", hole, segmentToRemove)) + } + } else if len(newHorizontalWall) > 1 { + if DEBUG_MAZE_GENERATION { + log.Print("Only a horizontal wall needs cut") + } + segmentToRemove := rand.Intn(len(newHorizontalWall) - 1) + hole := newHorizontalWall[segmentToRemove] + newHorizontalWall = remove(newHorizontalWall, segmentToRemove) + + disAllowedVertical = append(disAllowedVertical, hole.X) + if DEBUG_MAZE_GENERATION { + log.Print(fmt.Sprintf("Cuts are at %v from index %v", hole, segmentToRemove)) + } + } + + for _, point := range newVerticalWall { + tempBoardState.Hazards = append(tempBoardState.Hazards, point) + } + for _, point := range newHorizontalWall { + tempBoardState.Hazards = append(tempBoardState.Hazards, point) + } + + /// We have both so need 4 sub-rooms + if verticalWallPosition != -1 && horizontalWallPosition != -1 { + m.SubdivideRoom(tempBoardState, rand, rules.Point{X: lowPoint.X, Y: lowPoint.Y}, rules.Point{X: verticalWallPosition, Y: horizontalWallPosition}, disAllowedHorizontal, disAllowedVertical, depth+1) + m.SubdivideRoom(tempBoardState, rand, rules.Point{X: verticalWallPosition + 1, Y: lowPoint.Y}, rules.Point{X: highPoint.X, Y: horizontalWallPosition}, disAllowedHorizontal, disAllowedVertical, depth+1) + + m.SubdivideRoom(tempBoardState, rand, rules.Point{X: lowPoint.X, Y: horizontalWallPosition + 1}, rules.Point{X: verticalWallPosition, Y: highPoint.Y}, disAllowedHorizontal, disAllowedVertical, depth+1) + m.SubdivideRoom(tempBoardState, rand, rules.Point{X: verticalWallPosition + 1, Y: horizontalWallPosition + 1}, rules.Point{X: highPoint.X, Y: highPoint.Y}, disAllowedHorizontal, disAllowedVertical, depth+1) + } else if verticalWallPosition != -1 { + m.SubdivideRoom(tempBoardState, rand, rules.Point{X: lowPoint.X, Y: lowPoint.Y}, rules.Point{X: verticalWallPosition, Y: highPoint.Y}, disAllowedHorizontal, disAllowedVertical, depth+1) + m.SubdivideRoom(tempBoardState, rand, rules.Point{X: verticalWallPosition + 1, Y: lowPoint.Y}, rules.Point{X: highPoint.X, Y: highPoint.Y}, disAllowedHorizontal, disAllowedVertical, depth+1) + } else if horizontalWallPosition != -1 { + m.SubdivideRoom(tempBoardState, rand, rules.Point{X: lowPoint.X, Y: lowPoint.Y}, rules.Point{X: highPoint.X, Y: horizontalWallPosition}, disAllowedHorizontal, disAllowedVertical, depth+1) + m.SubdivideRoom(tempBoardState, rand, rules.Point{X: lowPoint.X, Y: horizontalWallPosition + 1}, rules.Point{X: highPoint.X, Y: highPoint.Y}, disAllowedHorizontal, disAllowedVertical, depth+1) + } + + return didSubdivide +} + +//////// Maze Helpers //////// + +func (m SoloMazeMap) AdjustPosition(mazePosition rules.Point, actualBoardSize int, boardHeight int, boardWidth int) rules.Point { + + xAdjust := int((boardWidth - actualBoardSize) / 2) + yAdjust := int((boardHeight - actualBoardSize) / 2) + + if DEBUG_MAZE_GENERATION { + fmt.Println(fmt.Sprintf("currentLevel: %v, boardHeight: %v, boardWidth: %v, xAdjust: %v, yAdjust: %v", actualBoardSize, boardHeight, boardWidth, xAdjust, yAdjust)) + } + + return rules.Point{X: mazePosition.X + xAdjust, Y: mazePosition.Y + yAdjust} +} + +func (m SoloMazeMap) ReadBitState(boardState *rules.BoardState) (int64, error) { + row := 0 + width := boardState.Width + + stringBits := "" + + for i := 0; i < width; i++ { + if containsPoint(boardState.Hazards, rules.Point{X: i, Y: row}) { + stringBits += "1" + } else { + stringBits += "0" + } + } + + return strconv.ParseInt(stringBits, 2, 64) +} + +func (m SoloMazeMap) WriteBitState(boardState *rules.BoardState, state int64, editor Editor) { + width := boardState.Width + + stringBits := strconv.FormatInt(state, 2) + paddingBits := fmt.Sprintf("%0*s", width, stringBits) + + for i, c := range paddingBits { + point := rules.Point{X: i, Y: 0} + + if c == '1' { + editor.AddHazard(point) + } else { + editor.RemoveHazard(point) + } + } +} + +/// Return value is first the wall that has been cut, the second is the holes we cut out +func cutHoles(s []rules.Point, intersection rules.Point, rand rules.Rand) ([]rules.Point, []rules.Point) { + holes := make([]rules.Point, 0) + + index := pos(s, intersection) + + if index != 0 { + firstSegmentToRemove := rand.Intn(index) + holes = append(holes, s[firstSegmentToRemove]) + s = remove(s, firstSegmentToRemove) + } + + index = pos(s, intersection) + if index != len(s)-1 { + secondSegmentToRemove := rand.Intn(len(s)-index-1) + index + 1 + + holes = append(holes, s[secondSegmentToRemove]) + s = remove(s, secondSegmentToRemove) + } + + return s, holes +} + +func cutHoleSingle(s []rules.Point, intersection rules.Point, rand rules.Rand) ([]rules.Point, []rules.Point) { + holes := make([]rules.Point, 0) + + index := pos(s, intersection) + + if index != 0 { + firstSegmentToRemove := rand.Intn(index) + holes = append(holes, s[firstSegmentToRemove]) + s = remove(s, firstSegmentToRemove) + + return s, holes + } + + if index != len(s)-1 { + secondSegmentToRemove := rand.Intn(len(s)-index-1) + index + 1 + + holes = append(holes, s[secondSegmentToRemove]) + s = remove(s, secondSegmentToRemove) + } + + return s, holes +} + +//////// Golang Helpers //////// + +func contains(s []int, e int) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} + +func containsPoint(s []rules.Point, e rules.Point) bool { + for _, a := range s { + if a.X == e.X && a.Y == e.Y { + return true + } + } + return false +} + +func remove(s []rules.Point, i int) []rules.Point { + s[i] = s[len(s)-1] + return s[:len(s)-1] +} + +func pos(s []rules.Point, e rules.Point) int { + for i, a := range s { + if a == e { + return i + } + } + return -1 +} + +func removeDuplicateValues(hazards []rules.Point) []rules.Point { + keys := make(map[rules.Point]bool) + uniqueList := []rules.Point{} + + for _, entry := range hazards { + if _, value := keys[entry]; !value { + keys[entry] = true + uniqueList = append(uniqueList, entry) + } + } + return uniqueList +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func manhattanDistance(a, b rules.Point) int { + return abs(a.X-b.X) + abs(a.Y-b.Y) +} + +func abs(a int) int { + if a < 0 { + return -a + } + return a +} + +//// DEBUGING HELPERS //// + +// This mostly copy pasted from the CLI which prints out the boardState +// Copied here to not create a circular dependency +// Removed some of the color picking logic to simplify things +func printMap(boardState *rules.BoardState) { + var o bytes.Buffer + o.WriteString(fmt.Sprintf("Turn: %v\n", boardState.Turn)) + board := make([][]string, boardState.Width) + for i := range board { + board[i] = make([]string, boardState.Height) + } + for y := int(0); y < boardState.Height; y++ { + for x := int(0); x < boardState.Width; x++ { + board[x][y] = "◦" + } + } + for _, oob := range boardState.Hazards { + board[oob.X][oob.Y] = "░" + } + for _, f := range boardState.Food { + board[f.X][f.Y] = "⚕" + } + o.WriteString(fmt.Sprintf("Food ⚕: %v\n", boardState.Food)) + for _, s := range boardState.Snakes { + for _, b := range s.Body { + if b.X >= 0 && b.X < boardState.Width && b.Y >= 0 && b.Y < boardState.Height { + board[b.X][b.Y] = string("*") + } + } + } + for y := boardState.Height - 1; y >= 0; y-- { + for x := int(0); x < boardState.Width; x++ { + o.WriteString(board[x][y]) + } + o.WriteString("\n") + } + log.Print(o.String()) +}