Byte-snake-engine/maps/solo_maze.go
2023-02-19 17:12:03 +00:00

547 lines
17 KiB
Go

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},
),
Tags: []string{TAG_EXPERIMENTAL, TAG_FOOD_PLACEMENT, TAG_HAZARD_PLACEMENT, TAG_SNAKE_PLACEMENT},
}
}
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) 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
}
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.Printf("Subdividing room from %v to %v", lowPoint, highPoint)
log.Printf("disAllowedVertical %v", disAllowedVertical)
log.Printf("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.Printf("drawing Vertical Wall at %v\n", 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.Printf("drawing horizontal Wall at %v\n", 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.Printf("Vertical Cuts are at %v\n", verticalHoles)
}
newNewHorizontalWall, horizontalHoles := cutHoleSingle(newHorizontalWall, intersectionPoint, rand)
newHorizontalWall = newNewHorizontalWall
for _, hole := range horizontalHoles {
disAllowedVertical = append(disAllowedVertical, hole.X)
}
if DEBUG_MAZE_GENERATION {
log.Printf("Horizontal Cuts are at %v\n", 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.Printf("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.Printf("Cuts are at %v from index %v", hole, segmentToRemove)
}
}
tempBoardState.Hazards = append(tempBoardState.Hazards, newVerticalWall...)
tempBoardState.Hazards = append(tempBoardState.Hazards, newHorizontalWall...)
/// 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.Printf("currentLevel: %v, boardHeight: %v, boardWidth: %v, xAdjust: %v, yAdjust: %v\n", 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())
}