from __future__ import annotations
import random
from dataclasses import dataclass, field
from typing import Dict, Iterator, List, Literal, TYPE_CHECKING
import tcod
import scripts.engine.core.matter
from scripts.engine.core import utility, world
from scripts.engine.internal import library
from scripts.engine.internal.constant import Direction, Height, MAP_BORDER_SIZE, TileCategory, TileCategoryType
from scripts.engine.internal.definition import ActorData, MapData, RoomConceptData
from scripts.engine.world_objects.tile import Tile
if TYPE_CHECKING:
    from typing import List, Optional, Tuple
__all__ = ["generate", "generate_steps"]
[docs]@dataclass
class DungeonGenerator:
    rng: random.Random
    # containers
    map_data: MapData
    rooms_data: Dict[str, RoomConceptData] = field(default_factory=dict)
    actors_data: Dict[str, ActorData] = field(default_factory=dict)
    placed_rooms: List[RoomConcept] = field(default_factory=list)
    map_of_categories: List[List[TileCategoryType]] = field(default_factory=list)
    positions_in_rooms: List[Tuple[int, int]] = field(default_factory=list)
    # parameters/config
    max_generate_room_attempts = 100  # lower number means likely less rooms
    max_place_room_attempts = 1000  # lower number means likely less rooms
    max_place_entrance_attempts = 50  # lower number means likely less entrances (and poss more ignorant tunnels)
    max_make_room_accessible_attempts = 100  # lower number means likely more ignorant tunnels
    max_place_entity_attempts = 50  # lower number means likely less entities
    border_size = MAP_BORDER_SIZE  # tiles to place around the outside of the map
    # info accessed via property but only created once requested
    _passable_map: List[List[Literal[True]]] = field(default_factory=list)
    _bools_map: List[List[bool]] = field(default_factory=list)
    _tiles_map: List[List[Tile]] = field(default_factory=list)
    # flags
    is_dirty: bool = False
[docs]    def is_in_bounds(self, x: int, y: int):
        """
        Check if a position is in the bounds of the map
        """
        if 0 < x < self.map_data.width - 1 and 0 < y < self.map_data.height - 1:
            return True
        else:
            return False 
[docs]    def is_in_room(self, x: int, y: int) -> bool:
        """
        Check if a position is in a placed room.
        """
        if (x, y) in self.positions_in_rooms:
            return True
        else:
            return False 
[docs]    def is_only_accessible_diagonally(self, x: int, y: int) -> bool:
        """
        Checks if a tile is only accessible via a diagonal move.
        """
        # can be reached by cardinal
        if self.count_adjacent_walls(x, y) > 0:
            return False
        # can be reached by diagonal
        if self.count_neighbouring_walls(x, y) > 0:
            return True
        # not accessible at all
        return False 
[docs]    def is_in_border(self, x: int, y: int):
        """
        Check if a position is in the border of the map
        """
        border_size = self.border_size
        width = self.map_data.width
        height = self.map_data.height
        # top and bottom
        for _x in range(0, width):
            for _y in range(0, border_size):
                if (_x, _y) == (x, y) or (_x, height - _y - 1) == (x, y):
                    return True
        # left and right
        for _x in range(0, border_size):
            for _y in range(0, height):
                if (_x, _y) == (x, y) or (width - _x - 1, _y) == (x, y):
                    return True
        return False 
[docs]    def count_neighbouring_walls(self, x: int, y: int) -> int:
        """
        Get the number of walls in 8 directions.
        """
        wall_counter = 0
        width = self.map_data.width
        height = self.map_data.height
        for neighbor_x in range(x - 1, x + 2):
            for neighbor_y in range(y - 1, y + 2):
                # Edges are considered alive. Makes map more likely to appear naturally closed.
                if neighbor_x < 0 or neighbor_y < 0 or neighbor_y >= height or neighbor_x >= width:
                    wall_counter += 1
                elif self.map_of_categories[neighbor_x][neighbor_y] == TileCategory.WALL:
                    # exclude (x,y) from adjacency check
                    if (neighbor_x != x) or (neighbor_y != y):
                        wall_counter += 1
        return wall_counter 
[docs]    def count_adjacent_walls(self, x: int, y: int) -> int:
        """
        Get the number of walls in 4 directions.
        """
        wall_counter = 0
        left = (x - 1, y)
        right = (x + 1, y)
        top = (x, y - 1)
        bot = (x, y + 1)
        for _x, _y in (left, right, top, bot):
            # count out of bounds as a wall
            if self.is_in_bounds(_x, _y):
                if self.map_of_categories[_x][_y] == TileCategory.WALL:
                    wall_counter += 1
            else:
                wall_counter += 1
        return wall_counter 
    @property
    def tiles_map(self) -> List[List[Tile]]:
        """
        Returns an array of Tiles by converting values from map_of_categories to tiles.
        """
        generated_level: List[List[Tile]] = []
        width = len(self.map_of_categories)  # use map of categories as this accounts for border
        height = len(self.map_of_categories[0])
        if self._tiles_map and not self.is_dirty:
            return self._tiles_map
        # build the full size map and fill with tunnel sprites
        for x in range(width):
            generated_level.append([])
            for y in range(height):
                tile = _create_tile_from_category(x, y, self.map_of_categories[x][y], self.map_data.sprite_paths)
                generated_level[x].append(tile)
        # overwrite tunnel sprites with room sprites
        for room in self.placed_rooms:
            sprite_paths = library.ROOMS[room.key].sprite_paths
            start_x = room.start_x
            start_y = room.start_y
            for x in range(room.width):
                for y in range(room.height):
                    target_x = min(start_x + x, width - 1)
                    target_y = min(start_y + y, height - 1)
                    tile = _create_tile_from_category(target_x, target_y, room.tile_categories[x][y], sprite_paths)
                    generated_level[target_x][target_y] = tile
        # update self
        self._tiles_map = generated_level
        self.is_dirty = False
        return self._tiles_map
    @property
    def bools_map(self) -> List[List[bool]]:
        """
        Returns an array of bools by converting values from map_of_categories to bool.
        Floor == True, Wall == False.
        """
        bools_map: List[List[bool]] = []
        width = len(self.map_of_categories)  # use map of categories as this accounts for border
        height = len(self.map_of_categories[0])
        if self._bools_map and not self.is_dirty:
            return self._bools_map
        for x in range(width):
            bools_map.append([])
            for y in range(height):
                if self.map_of_categories[x][y] == TileCategory.FLOOR:
                    bools_map[x].append(True)
                else:
                    bools_map[x].append(False)
        # update self
        self._bools_map = bools_map
        self.is_dirty = False
        return self._bools_map
    @property
    def passable_map(self) -> List[List[Literal[True]]]:
        """
        2d array of True, matching map size
        """
        passable_map: List[List[Literal[True]]] = []
        width = len(self.map_of_categories)  # use map of categories as this accounts for border
        height = len(self.map_of_categories[0])
        # if we have the passable map already created
        if self._passable_map and not self.is_dirty:
            return self._passable_map
        for x in range(width):
            passable_map.append([])
            for y in range(height):
                passable_map[x].append(True)
        # update self
        self._passable_map = passable_map
        self.is_dirty = False
        return self._passable_map
    @property
    def generation_string(self) -> str:
        # add map info
        gen_info = "==== DUNGEN ==== \n"
        gen_info += f"==== Map Details ==== \n"
        gen_info += f"{self.map_data.name} | w:{self.map_data.width}, h:{self.map_data.height} | "
        gen_info += f"border:{self.border_size} | rooms: {len(self.placed_rooms)} \n"
        # add info from each room
        gen_info += f"==== Room Details ==== \n"
        for room in self.placed_rooms:
            gen_info += room.generation_info + "\n"
        # add tile info
        gen_info += f"==== Tile Details ==== \n"
        for row in self.map_of_categories:
            gen_info += f"{row} \n"
        return gen_info
[docs]    def get_room(self, x: int, y: int) -> Optional[RoomConcept]:
        """
        Returns the room at xy.
        """
        _room = RoomConcept([], "", "", x, y)
        for room in self.placed_rooms:
            if room.intersects(_room):
                return room
        return None 
[docs]    def get_room_data(self, key: str) -> RoomConceptData:
        """
        Get the data for a room based on key.
        """
        if key in self.rooms_data:
            room_data = self.rooms_data[key]
        else:
            room_data = library.ROOMS[key]
            self.rooms_data[key] = room_data
        return room_data 
[docs]    def get_actor_data(self, key: str) -> ActorData:
        """
        Get the data for an actor based on key.
        """
        if key in self.actors_data:
            actor_data = self.actors_data[key]
        else:
            actor_data = library.ACTORS[key]
            self.actors_data[key] = actor_data
        return actor_data 
[docs]    def create_entities(self):
        """
        Create all entities listed in rooms
        """
        for room in self.placed_rooms:
            for actor_key, pos in room.actors.items():
                actor_data = self.get_actor_data(actor_key)
                # create actor
                if actor_key != "player":
                    actor = scripts.engine.core.matter.create_actor(actor_data, (pos[0], pos[1]))
                else:
                    actor = scripts.engine.core.matter.create_actor(actor_data, (pos[0], pos[1]), True) 
[docs]    def set_tile_category(self, x: int, y: int, category: TileCategoryType):
        """
        Set the tile category at xy in map_of_categories. Marks map as dirty
        """
        self.map_of_categories[x][y] = category
        self.is_dirty = True  
[docs]@dataclass
class RoomConcept:
    """
    Details of a room. Used for world generation.
    """
    # what to place in a tile. Needed to hold values when returning room
    tile_categories: List[List[TileCategoryType]]
    design: str  # algorithm used to generate
    key: str  # the type of room placed
    start_x: int = -1
    start_y: int = -1
    actors: Dict[str, Tuple[int, int]] = field(default_factory=dict)  # key, position
    @property
    def id(self) -> str:
        """
        Return the id. Uses xy.
        """
        _id = f"{self.start_x},{self.start_y}"
        return _id
    @property
    def available_area(self) -> int:
        """
        Number of unblocked tiles.
        """
        unblocked_count = 0
        for row in self.tile_categories:
            for tile_cat in row:
                if tile_cat == TileCategory.FLOOR:
                    unblocked_count += 1
        return unblocked_count
    @property
    def total_area(self) -> int:
        """
        Number of tiles in room.
        """
        count = 0
        for row in self.tile_categories:
            for tile_cat in row:
                count += 1
        return count
    @property
    def width(self) -> int:
        """
        Widest width.
        """
        try:
            width = len(self.tile_categories)
        except IndexError:
            raise Exception("Something referenced room width before the room had any tile categories.")
        return width
    @property
    def height(self) -> int:
        """
        Tallest height
        """
        try:
            height = len(self.tile_categories[0])
        except IndexError:
            raise Exception("Something referenced room height before the room had any tile categories.")
        return height
    @property
    def generation_info(self) -> str:
        """
        Return the generation information about the room
        """
        gen_info = (
            f"{self.key} | {self.design} | {self.id} | w:{self.width}, h:{self.height} | "
            f"available:{self.available_area}/ total:{self.total_area} | actors:{self.actors}"
        )
        return gen_info
    @property
    def end_x(self) -> int:
        return self.start_x + self.width
    @property
    def end_y(self) -> int:
        return self.start_y + self.height
    @property
    def centre_x(self) -> int:
        return (self.width // 2) + self.start_x
    @property
    def centre_y(self) -> int:
        return (self.height // 2) + self.start_y
[docs]    def intersects(self, room: RoomConcept) -> bool:
        """
        Check if this room intersects with another.
        """
        if (self.start_x <= room.end_x and self.end_x >= room.start_x) and (
            self.start_y <= room.end_y and self.end_y >= room.start_y
        ):
            return True
        return False  
############################ GENERATE MAP ############################
[docs]def generate(
    map_name: str, rng: random.Random, player_data: Optional[ActorData] = None
) -> Tuple[List[List[Tile]], str]:
    """
    Generate the map using the specified details.
    """
    # create generator
    dungen = DungeonGenerator(rng, library.MAPS[map_name])
    # generate the level
    for _ in _generate_map_in_steps(dungen):
        pass
    # generate entities
    for _ in _generate_entities_in_steps(dungen, player_data):
        pass
    # create the generated entities
    dungen.create_entities()
    return dungen.tiles_map, dungen.generation_string 
[docs]def generate_steps(map_name: str) -> Iterator:
    """
    Generates a map, returning each step of the generation. Used for dev view.
    """
    # create generator
    dungen = DungeonGenerator(random.Random(), library.MAPS[map_name])
    for step in _generate_map_in_steps(dungen):
        yield step
    for step in _generate_entities_in_steps(dungen):
        yield step 
def _generate_map_in_steps(dungen: DungeonGenerator) -> Iterator:
    """
    Generate the next step of the map generation.
    """
    rooms_placed = 0
    placement_attempts = 0
    rooms_generated = 0
    # set everything to walls
    dungen.map_of_categories = []
    map_width = dungen.map_data.width
    map_height = dungen.map_data.height
    for x in range(map_width + (dungen.border_size * 2)):
        dungen.map_of_categories.append([])  # create new list for every col
        for y in range(map_height + (dungen.border_size * 2)):
            dungen.map_of_categories[x].append(TileCategory.WALL)
    yield dungen.map_of_categories
    # get room options for this map
    rooms = dungen.map_data.rooms
    room_names = []
    room_weights = []
    # break out names and weights
    for name, weight in rooms.items():
        room_names.append(name)
        room_weights.append(weight)
    # generate and place rooms
    max_rooms = dungen.rng.randint(dungen.map_data.min_rooms, dungen.map_data.max_rooms)
    max_generate_room_attempts = dungen.max_generate_room_attempts
    while rooms_placed <= max_rooms and rooms_generated <= max_generate_room_attempts:
        found_place = False
        room = _generate_room(dungen, room_names, room_weights)
        rooms_generated += 1
        # find place for the room
        while placement_attempts < dungen.max_place_room_attempts and not found_place:
            placement_attempts += 1
            found_place = _place_room(dungen, room)
        # if no place found for the room try again
        if not found_place:
            continue
        # doesnt intersect so paint room on map and add room to list
        for room_x in range(room.width):
            for room_y in range(room.height):
                map_x = room.start_x + room_x
                map_y = room.start_y + room_y
                in_bounds = dungen.is_in_bounds(map_x, map_y)
                in_border = dungen.is_in_border(map_x, map_y)
                if in_bounds and not in_border:
                    dungen.map_of_categories[map_x][map_y] = room.tile_categories[room_x][room_y]
                    dungen.positions_in_rooms.append((map_x, map_y))
        # place room
        dungen.placed_rooms.append(room)
        rooms_placed += 1
        # yield map after each room is painted
        yield dungen.map_of_categories
    # handle anything dodgy
    if rooms_placed == 0:
        raise Exception("No rooms placed on the map.")
    # room placement complete, fill tunnels, without connecting to rooms
    for x in range(map_width):
        for y in range(map_height):
            # check is not part of a room
            if not dungen.is_in_room(x, y):
                # if its a wall, start a tunnel
                if dungen.map_of_categories[x][y] == TileCategory.WALL:
                    if _add_tunnel(dungen, x, y):
                        yield dungen.map_of_categories
    # join rooms to tunnels
    for room in dungen.placed_rooms:
        _add_entrances(dungen, room)
        yield dungen.map_of_categories
    _make_rooms_accessible(dungen)
    yield dungen.map_of_categories
    # N.B. do this last to keep more of the random tunnels
    _remove_deadends(dungen)
    yield dungen.map_of_categories
def _generate_entities_in_steps(dungen: DungeonGenerator, player_data: Optional[ActorData] = None) -> Iterator:
    """
    Add entities to all rooms using the room data. Also places player.
    """
    # randomise order of rooms
    rooms = dungen.placed_rooms
    dungen.rng.shuffle(rooms)
    # we might not have player data if we are viewing the generated maps
    if player_data:
        # put player in first room
        player_room = rooms[0]
        placed = False
        placement_attempts = 0
        while placement_attempts <= 1000 and not placed:
            xy = _find_place_for_actor(dungen, player_room, player_data)
            placement_attempts += 1
            if xy:
                x, y = xy
                # add player data to list so we can use it when creating the entities
                dungen.actors_data["player"] = player_data
                # log actor in room
                player_room.actors["player"] = (x, y)
                dungen.set_tile_category(x, y, TileCategory.PLAYER)
                placed = True
        # just in case player hasnt been placed
        if not placed:
            raise Exception("Dungen: Unable to place player.")
    yield dungen.map_of_categories
    # work through all rooms and populate
    skipped_player_room = False
    for room in rooms:
        # make sure to skip player room as that is handled differently
        if not skipped_player_room:
            skipped_player_room = True
            continue
        room_key = room.key
        room_data = dungen.get_room_data(room_key)
        # generate the actor
        actors_placed = 0
        placement_attempts = 0
        max_attempts = dungen.max_place_entity_attempts
        max_actors = dungen.rng.randint(room_data.min_actors, room_data.max_actors)
        # if this room is empty of actors go to next room
        if max_actors == 0:
            continue
        # try and place the actor
        while actors_placed <= max_actors and placement_attempts <= max_attempts:
            actor_data = _generate_actor(dungen, room_data)
            # try to place the actor
            xy = _find_place_for_actor(dungen, room, actor_data)
            placement_attempts += 1
            if xy:
                x, y = xy
                # log actor in room (for generation string)
                room.actors[actor_data.key] = (x, y)
                # mark actors on map, handle multi tile.
                for pos in actor_data.position_offsets:
                    dungen.set_tile_category(x + pos[0], y + pos[1], TileCategory.ACTOR)
                actors_placed += 1
                yield dungen.map_of_categories
############################ ROOMS ############################
def _generate_room(dungen: DungeonGenerator, room_names: List[str], room_weights: List[float]) -> RoomConcept:
    """
    Select a room type to generate and return that room. If a generation method isnt provided then one is picked at
    random, using weightings in the data.
    """
    # N.B. add more generation methods here
    design_methods = {
        "square": _generate_room_square,
    }
    # pick a room based on weights
    room_name = dungen.rng.choices(room_names, room_weights, k=1)[0]
    room_data = library.ROOMS[room_name]
    room = design_methods[room_data.design](dungen, room_data)
    return room
def _generate_room_square(dungen: DungeonGenerator, room_data: RoomConceptData) -> RoomConcept:
    """
    Generate a square-shaped room.
    """
    map_width = dungen.map_data.width
    map_height = dungen.map_data.height
    # ensure not bigger than the map
    room_width = min(dungen.rng.randint(room_data.min_width, room_data.max_width), map_width)
    room_height = min(dungen.rng.randint(room_data.min_height, room_data.max_height), map_height)
    # populate area with floor categories
    tile_categories: List[List[TileCategoryType]] = []
    for x in range(room_width):
        tile_categories.append([])
        for y in range(room_height):
            tile_categories[x].append(TileCategory.FLOOR)
    # convert to room
    room = RoomConcept(tile_categories=tile_categories, design="square", key=room_data.key)
    return room
def _place_room(dungen: DungeonGenerator, room: RoomConcept) -> bool:
    """
    Place room in random location. Updates room start_x and start_y. Returns True if valid placement found.
    """
    intersects = False
    map_width = dungen.map_data.width
    map_height = dungen.map_data.height
    border_size = dungen.border_size
    # pick random location to place room, not including borders
    room.start_x = dungen.rng.randint(border_size, max(border_size, map_width - room.width - 1))
    room.start_y = dungen.rng.randint(border_size, max(border_size, map_height - room.height - 1))
    # if placed there does room overlap any existing rooms?
    for _room in dungen.placed_rooms:
        if room.intersects(_room):
            intersects = True
            break
    if not intersects:
        return True
    else:
        return False
####################### MAP AMENDMENTS ##############################
def _add_tunnel(dungen: DungeonGenerator, x: int, y: int) -> bool:
    """
    Follow a path from origin (xy) setting relevant position in map_of_categories to TileCategory.FLOOR. Uses flood
    fill. Returns True if tunnel added.
    """
    added_tunnel = False
    to_fill_positions = set()
    last_direction = (0, 0)
    # check position we've been given is OK before entering loop
    in_bounds = dungen.is_in_bounds(x, y)
    in_border = dungen.is_in_border(x, y)
    in_room = dungen.is_in_room(x, y)
    num_walls = dungen.count_neighbouring_walls(x, y)  # only start where no other floors are
    if in_bounds and not in_room and not in_border and num_walls >= 8:
        # first position is good, add to list
        to_fill_positions.add((x, y))
    while to_fill_positions:
        possible_directions = []
        # get next pos in set
        _x, _y = to_fill_positions.pop()
        # convert to floor
        dungen.set_tile_category(_x, _y, TileCategory.FLOOR)
        dungen.positions_in_rooms.append((_x, _y))
        added_tunnel = True
        # check for appropriate, adjacent wall tiles
        for x_dir, y_dir in (Direction.UP, Direction.DOWN, Direction.LEFT, Direction.RIGHT):
            x_check = _x + x_dir
            y_check = _y + y_dir
            in_bounds = dungen.is_in_bounds(x_check, y_check)
            in_room = dungen.is_in_room(x_check, y_check)
            in_border = dungen.is_in_border(x_check, y_check)
            num_walls = dungen.count_adjacent_walls(x_check, y_check)  # can only move cardinal so check that
            # direction must be in bounds, not in a room and be surrounded by walls on all but X sides
            # must also not be adjacent to a room - i.e. can connect to a tunnel but not a room.
            # N.B. the lower the num walls the more overlapping and joined up the tunnels are
            if in_bounds and not in_room and not in_border and num_walls >= 3:
                if dungen.map_of_categories[x_check][y_check] == TileCategory.WALL and not dungen.is_in_room(
                    _x + (x_dir * 2), _y + (y_dir * 2)
                ):
                    possible_directions.append((x_dir, y_dir))
        # choose next direction to go in
        if possible_directions:
            # pick a possible position, preferring previous direction, unless tunnel winds
            if (
                last_direction in possible_directions
                and dungen.rng.randint(1, 100) > dungen.map_data.chance_of_tunnel_winding
            ):
                new_direction = last_direction
            else:
                new_direction = dungen.rng.choice(possible_directions)
            # add next position to be checked
            to_fill_positions.add((_x + new_direction[0], _y + new_direction[1]))
            # update last direction
            last_direction = new_direction
    return added_tunnel
def _add_ignorant_tunnel(dungen: DungeonGenerator, start_x: int, start_y: int, end_x: int, end_y: int):
    """
    Create a tunnel between two points, ignoring all terrain on the way
    """
    x = min(start_x, end_x)
    y = min(start_y, end_y)
    max_x = max(start_x, end_x) - 1
    max_y = max(start_y, end_y) - 1
    while x < max_x:
        if dungen.is_in_bounds(x, y):
            dungen.set_tile_category(x, y, TileCategory.FLOOR)
        x += 1
    while y < max_y:
        if dungen.is_in_bounds(x, y):
            dungen.set_tile_category(x, y, TileCategory.FLOOR)
        y += 1
def _add_entrances(dungen: DungeonGenerator, room: RoomConcept):
    """
    Loop the outer edge of the room for two adjoining floors and break through to link the locations.
    """
    entrances = 0
    attempts = 0
    placed_entrances = set()
    # print(f"Add entrance to room: x:{room.start_x} | end_x:{room.end_x} | y:{room.start_y} | end_y:{room.end_y}")
    # roll for an extra entrance
    base_num_entrances = dungen.map_data.max_room_entrances
    if dungen.rng.randint(1, 100) >= dungen.map_data.extra_entrance_chance:
        _max_entrances = base_num_entrances + 1
    else:
        _max_entrances = base_num_entrances
    # roll for max entrances, ensure minimum 1
    max_entrances = dungen.rng.randint(1, max(1, _max_entrances))
    # find somewhere to place the entrance
    while attempts <= dungen.max_place_entrance_attempts and entrances <= max_entrances:
        attempts += 1
        poss_positions = []
        # pick random positions
        top_pos = (room.start_x + dungen.rng.randint(1, room.width - 1), room.start_y - 1)
        top_pos2 = top_pos[0], top_pos[1] - 1
        bot_pos = (room.start_x + dungen.rng.randint(1, room.width - 1), room.start_y + room.height + 1)
        bot_pos2 = bot_pos[0], bot_pos[1] + 1
        left_pos = (room.start_x - 1, room.start_y + dungen.rng.randint(1, room.height - 1))
        left_pos2 = left_pos[0] - 1, left_pos[1]
        right_pos = (room.start_x + room.width + 1, room.start_y + dungen.rng.randint(1, room.height - 1))
        right_pos2 = right_pos[0] + 1, right_pos[1]
        # print(f"-> Random pos: top:{top_pos}:{top_pos2} | bot:{bot_pos}:{bot_pos2} | left:{left_pos}"
        #       f":{left_pos2} | right:{right_pos}:{right_pos2}")
        # note which ones are applicable
        for _pos, _pos2 in ((top_pos, top_pos2), (bot_pos, bot_pos2), (left_pos, left_pos2), (right_pos, right_pos2)):
            pos2_is_floor = False
            x, y = _pos2
            # if second pos in bounds then first and target must be
            in_bounds = dungen.is_in_bounds(x, y)
            in_border = dungen.is_in_border(x, y)
            if in_bounds and not in_border:
                if dungen.map_of_categories[x][y] == TileCategory.FLOOR:
                    pos2_is_floor = True
            # if target is wall and one after is floor and not already a placed entrance
            if pos2_is_floor and _pos not in placed_entrances:
                poss_positions.append(_pos)
        # pick one of the possible options and update the map
        if poss_positions:
            pos = dungen.rng.choice(poss_positions)
            dungen.set_tile_category(pos[0], pos[1], TileCategory.FLOOR)
            placed_entrances.add(pos)
            entrances += 1
def _remove_deadends(dungen: DungeonGenerator):
    """
    Find all instances where a tunnel has a deadend and uncarve it, converting it back to a wall.
    """
    deadends = set()
    # find initial deadends
    for x in range(0, dungen.map_data.width):
        for y in range(0, dungen.map_data.height):
            if dungen.map_of_categories[x][y] == TileCategory.FLOOR and dungen.count_adjacent_walls(x, y) >= 3:
                deadends.add((x, y))
    while deadends:
        _x, _y = deadends.pop()
        # mark as wall
        dungen.set_tile_category(_x, _y, TileCategory.WALL)
        # check around where we just amended for new deadends
        for x_dir, y_dir in (
            Direction.UP,
            Direction.DOWN,
            Direction.LEFT,
            Direction.RIGHT,
            Direction.UP_LEFT,
            Direction.UP_RIGHT,
            Direction.DOWN_LEFT,
            Direction.DOWN_RIGHT,
        ):
            x_check = _x + x_dir
            y_check = _y + y_dir
            in_bounds = dungen.is_in_bounds(x_check, y_check)
            if in_bounds:
                tile_cat = dungen.map_of_categories[x_check][y_check]
                num_walls = dungen.count_adjacent_walls(x_check, y_check)
                if num_walls >= 3 and tile_cat == TileCategory.FLOOR:
                    deadends.add((x_check, y_check))
def _make_rooms_accessible(dungen: DungeonGenerator):
    """
    Pick a room as the anchor and make sure all other floor tiles can connect to it via pathfinding.
    """
    connections: Dict[str, List[str]] = {}  # room, connected rooms
    inaccessible_rooms = dungen.placed_rooms.copy()
    # start by picking one room that all other rooms should connect to
    anchor_room = dungen.rng.choice(dungen.placed_rooms)
    # pick spot in room as anchor and make sure it is open
    anchor_x = anchor_room.centre_x
    anchor_y = anchor_room.centre_y
    dungen.set_tile_category(anchor_x, anchor_y, TileCategory.FLOOR)
    # loop all rooms until they can all get to the anchor room
    while inaccessible_rooms:
        room = inaccessible_rooms.pop()
        # dont check anchor room
        if room == anchor_room:
            continue
        room_x = room.centre_x
        room_y = room.centre_y
        # ensure the centre of the room is open
        dungen.set_tile_category(room_x, room_y, TileCategory.FLOOR)
        # check if route is possible between anchor and target
        pathfinder = tcod.path.Dijkstra(dungen.bools_map, 0)
        pathfinder.set_goal(room_x, room_y)
        path = pathfinder.get_path(anchor_x, anchor_y)
        # if no possible route, make one
        if not path:
            # get ignore list i.e. connected rooms
            ignore_list = []
            for _room_id, connected_rooms_ids in connections.items():
                if _room_id == room.id:
                    # if the key matches then add all of the values in the list
                    ignore_list.extend(connected_rooms_ids)
                elif room.id in connected_rooms_ids:
                    # if id in the value list then add the key
                    ignore_list.append(_room_id)
            # get nearest room and tunnel to that or the anchor
            nearest_room = _get_nearest_room(dungen, room, ignore_list)
            if nearest_room:
                _add_ignorant_tunnel(dungen, room_x, room_y, nearest_room.centre_x, nearest_room.centre_y)
                # log the connection
                if room.id in connections:
                    connections[room.id].append(nearest_room.id)
                else:
                    connections[room.id] = [nearest_room.id]
                # add back to list to check can now reach anchor
                inaccessible_rooms.append(room)
            else:
                _add_ignorant_tunnel(dungen, room_x, room_y, anchor_room.centre_x, anchor_room.centre_y)
        else:
            # we have a path so log the connection to the anchor room
            if anchor_room.id in connections:
                connections[anchor_room.id].append(room.id)
            else:
                connections[anchor_room.id] = [room.id]
def _get_nearest_room(
    dungen: DungeonGenerator, base_room: RoomConcept, id_ignore_list: List[str]
) -> Optional[RoomConcept]:
    """
    Starting from xy find the directly nearest room. Treats all tiles as floors.
    """
    nearest_room = None
    shortest_path_length = 0
    base_x = base_room.centre_x
    base_y = base_room.centre_y
    # create pathfinder
    pathfinder = tcod.path.Dijkstra(dungen.passable_map, 0)
    pathfinder.set_goal(base_x, base_y)
    # check each room to see which is closest
    for room in dungen.placed_rooms:
        # check ignore list
        if room.id in id_ignore_list or room.id == base_room.id:
            continue
        # create new path
        path = pathfinder.get_path(room.centre_x, room.centre_y)
        path_length = len(path)
        # if we havent got any room yet set values
        if not nearest_room:
            nearest_room = room
            shortest_path_length = path_length
            continue
        # check length
        if path_length < shortest_path_length:
            shortest_path_length = path_length
            nearest_room = room
    return nearest_room
####################### ENTITIES ##############################
def _generate_actor(dungen: DungeonGenerator, room_data: RoomConceptData) -> ActorData:
    """
    Pick an actor using the possible options in the room and their weighting and return the actor's data.
    """
    # pick an actor
    keys = []
    weights = []
    for key, weight in room_data.actors.items():
        keys.append(key)
        weights.append(weight)
    actor_key = dungen.rng.choices(keys, weights, k=1)[0]
    # get the actor data
    actor_data = dungen.get_actor_data(actor_key)
    return actor_data
def _find_place_for_actor(
    dungen: DungeonGenerator, room: RoomConcept, actor_data: ActorData
) -> Optional[Tuple[int, int]]:
    """
    Keep picking random locations in a room to place the actor. Returns xy if successful.
    """
    # pick random location to place actor
    x = room.start_x + dungen.rng.randint(1, max(1, room.width - 1))
    y = room.start_y + dungen.rng.randint(1, max(1, room.height - 1))
    # check if actor is blocked
    blocked = True
    for pos in actor_data.position_offsets:
        offset_x = x + pos[0]
        offset_y = y + pos[1]
        # only need to check tile category as that captures entity placement too
        if dungen.map_of_categories[offset_x][offset_y] == TileCategory.FLOOR:
            blocked = False
        else:
            blocked = True
    # if blocked try again
    if not blocked:
        return x, y
    else:
        return None
####################### HELPER FUNCTIONS ##############################
def _create_tile_from_category(x: int, y: int, tile_category: TileCategoryType, sprite_paths: Dict[str, str]) -> Tile:
    """
    Convert a tile category into the relevant tile. If it isnt a wall it is floor by default.
    """
    if tile_category == TileCategory.WALL:
        sprite_path = sprite_paths[TileCategory.WALL]
        sprite = utility.get_image(sprite_path)
        height = Height.MAX
        blocks_movement = True
    else:
        # everything else is considered a floor:
        sprite_path = sprite_paths[TileCategory.FLOOR]
        sprite = utility.get_image(sprite_path)
        height = Height.MIN
        blocks_movement = False
    tile = Tile(x, y, sprite, sprite_path, blocks_movement, height)
    return tile