diff --git a/pelita/maze_generator.py b/pelita/maze_generator.py index 12ed0a614..e1146a665 100644 --- a/pelita/maze_generator.py +++ b/pelita/maze_generator.py @@ -40,10 +40,18 @@ from .team import walls_to_graph +# minimum partition width +MIN_WIDTH = 5 + +# minimum partition height +MIN_HEIGHT = 5 + +# partition padding for wall position sampling +PADDING = 2 + + def mirror(nodes, width, height): - nodes = set(nodes) - other = set((width - 1 - x, height - 1 - y) for x, y in nodes) - return nodes | other + return set((width - 1 - x, height - 1 - y) for x, y in nodes) def sample_nodes(nodes, k, rng=None): @@ -55,21 +63,17 @@ def sample_nodes(nodes, k, rng=None): return nodes -def find_trapped_tiles(graph, width, include_chambers=False): +def find_trapped_tiles(graph, gaps, include_chambers=False): main_chamber = set() chamber_tiles = set() for chamber in nx.biconnected_components(graph): - max_x = max(chamber, key=lambda n: n[0])[0] - min_x = min(chamber, key=lambda n: n[0])[0] - if min_x < width // 2 <= max_x: - # only the main chamber covers both sides - # our own mazes should only have one central chamber - # but other configurations could have more than one + if (chamber & gaps): + # main chambers intersect with border gaps main_chamber.update(chamber) - continue else: - chamber_tiles.update(set(chamber)) + # side chambers don't as the border is centrosymmetric + chamber_tiles.update(chamber) # remove shared articulation points with the main chamber chamber_tiles -= main_chamber @@ -78,10 +82,10 @@ def find_trapped_tiles(graph, width, include_chambers=False): if include_chambers: subgraphs = graph.subgraph(chamber_tiles) chambers = list(nx.connected_components(subgraphs)) - else: - chambers = [] - return chamber_tiles, chambers + return chamber_tiles, chambers + + return chamber_tiles def distribute_food(all_tiles, chamber_tiles, trapped_food, total_food, rng=None): @@ -118,132 +122,165 @@ def distribute_food(all_tiles, chamber_tiles, trapped_food, total_food, rng=None return tf_pos | ff_pos | leftover_food_pos -def add_wall_and_split(partition, walls, ngaps, vertical, rng=None): +def sample(x, k, rng): + # temporary replacement wrapper for `rng.shuffle` conformant with + # the `random.sample` API (minus the `count` parameter) + + # copy population + result = x.copy() + + # shuffle all items + rng.shuffle(result) + + # return the first `k` results + return result[:k] + + +def identity(a, b): + # identity transformation + return a, b + + +def transposition(a, b): + # transposing transformation + return b, a + + +def add_inner_walls(pmin, pmax, walls, ngaps, vertical, rng=None): rng = default_rng(rng) - # store partitions in an expanding list - # alongside the number of gaps in wall and its orientation - partitions = [partition + (ngaps, vertical)] - - # partition index - p = 0 - - # The infinite loop is always exiting, since the position of the walls - # `pos` is in `[xmin + 1, xmax - (xmin + 1)]` or - # in `[ymin + 1, ymax - (ymin + 1)]`, respectively, and thus always - # yielding partitions smaller than the current partition. - # The checks for `height < 3`, `width < 3` and - # `partition_length < rng.randint(3, 5)` ensure no further addition of - # partitions, and `p += 1` in those checks and after partitioning ensure - # that we always advance in the list of partitions. - # - # So, partitions always shrink, no new partitions are added once they - # shrank below a threshold, and the loop increases the list index in - # every case. - while True: - # get the next partition of any is available - try: - partition = partitions[p] - except IndexError: - break - - (xmin, ymin), (xmax, ymax), ngaps, vertical = partition - - # the size of the maze partition we work on - width = xmax - xmin + 1 - height = ymax - ymin + 1 + # ensure a connected maze by a minimum of 1 sampled gap + ngaps = max(1, ngaps) + + # copy framing walls to avoid side effects + walls = walls.copy() + + # store partitions in an expanding list alongside the number of gaps and + # the orientation of the wall + partitions = [(pmin, pmax, ngaps, vertical)] + + # loop over all occuring partitions in the list; + # the loop always exits because partitions always shrink by definition, + # no new partitions are added once they shrank below a threshold and + # the list of partitions is always drained on every iteration + while len(partitions) > 0: + # + # DEFINITIONS + # + + # A partition with its variables in `u`-`v`-space is described as: + # + # ┌─► u + # ▼ + # v umin pos umax + # + # | | | + # vmin ── O──────────O──────────┐ + # │ pmin │ wmin │ + # │ │ │ + # │ │ │ + # │ │ │ + # │ │ │ + # vmax ── └──────────O──────────O + # wmax pmax + # + # + # Note: the inner wall is always vertical. + # + # + # Partition framing points are defined as: + # + # pmin = (umin, vmin) + # pmax = (umax, vmax) + # + # + # Wall start and end points are defined as: + # + # wmin = (pos, vmin) + # wmax = (pos, vmax) + + # get the next partition + pmin, pmax, ngaps, vertical = partitions.pop() + xmin, ymin = pmin + xmax, ymax = pmax + + # if vertical, preserve the coordinates, else transpose them + transform = identity if vertical else transposition + + # map `x`-`y`-coordinates into `u`-`v`-space where the inner wall is + # always vertical + (umin, umax), (vmin, vmax) = transform((xmin, xmax), (ymin, ymax)) + + # the size of the maze partition we work on in `u`-`v`-space + ulen = umax - umin + 1 + vlen = vmax - vmin + 1 # if the partition is too small, move on with the next one - if height < 3 and width < 3: - p += 1 + if ulen < MIN_WIDTH and vlen < MIN_HEIGHT: continue - # insert a wall only if there is some space in the around it in the - # orthogonal direction, i.e.: - # if the wall is vertical, then the relevant length is the width - # if the wall is horizontal, then the relevant length is the height, - # otherwise move on with the next one - partition_length = width if vertical else height - if partition_length < rng.randint(3, 5): - p += 1 + # insert a wall only if there is some space around it in the + # orthogonal `u`-direction, otherwise move on with the next partition + if ulen < rng.randint(MIN_WIDTH, MIN_WIDTH + 2): continue - # the row/column to put the horizontal/vertical wall on - # the position is calculated starting from the left/top of the maze partition - # and then a random offset is added -> the resulting raw/column must not - # exceed the available length - pos = xmin if vertical else ymin - pos += rng.randint(1, partition_length - 2) - - # the maximum length of the wall is the space we have in the same direction - # of the wall in the partition, i.e. - # if the wall is vertical, the maximum length is the height - # if the wall is horizontal, the maximum length is the width - max_length = height if vertical else width - - # We can start with a full wall, but we want to make sure that we do not - # block the entrances to this partition. The entrances are - # - the tile before the beginning of this wall [entrance] and - # - the tile after the end of this wall [exit] - # if entrance or exit are _not_ walls, then the wall must leave the neighboring - # tiles also empty, i.e. the wall must be shortened accordingly - if vertical: - entrance_before = (pos, ymin - 1) - entrance_after = (pos, ymin + max_length) - begin = 0 if entrance_before in walls else 1 - end = max_length if entrance_after in walls else max_length - 1 - wall = {(pos, ymin + y) for y in range(begin, end)} - else: - entrance_before = (xmin - 1, pos) - entrance_after = (xmin + max_length, pos) - begin = 0 if entrance_before in walls else 1 - end = max_length if entrance_after in walls else max_length - 1 - wall = {(xmin + x, pos) for x in range(begin, end)} - - # place the requested number of gaps in the otherwise full wall - # these gaps are indices in the direction of the wall, i.e. - # x if horizontal and y if vertical - # TODO: when we drop compatibility with numpy, this can be more easily done - # by just sampling ngaps out of the full wall set, i.e. - # gaps = rng.sample(wall, k=ngaps) - # for gap in gaps: - # wall.remove(gap) - ngaps = max(1, ngaps) - wall_pos = list(range(max_length)) - rng.shuffle(wall_pos) - - for gap in wall_pos[:ngaps]: - if vertical: - wall.discard((pos, ymin + gap)) - else: - wall.discard((xmin + gap, pos)) + # + # WALL SAMPLING + # + + # choose a coordinate within the partition length in `u`-direction + pos = rng.randint(umin + PADDING, umax - PADDING) + + # define start and end of the inner wall in `x`-`y`-space + wmin = transform(pos, vmin) + wmax = transform(pos, vmax) + + # set start and end for the wall slice dependent on present entrances + above = 1 if wmin in walls else 2 + below = 1 if wmax in walls else 2 + + # sliced continuous wall in `x`-`y`-space + wall = {transform(pos, v) for v in range(vmin + above, vmax - below + 1)} + # sample gap coordinates along the wall, i.e in `v`-direction + # + # TODO: + # when we drop compatibility with numpy mazes, the range of sampled + # gaps can be adjusted to remove them directly from the full wall + # OR we sample the wall segments to keep with k = len(wall) - ngaps + gaps = list(range(vmin + 1, vmax)) + gaps = sample(gaps, ngaps, rng) + + # combine gap coordinates to wall gaps in `x`-`y`-space + sampled = {transform(pos, v) for v in gaps} + + # remove sampled gaps from the wall + wall -= sampled # collect this wall into the global wall set walls |= wall - # define the two new partitions of the maze generated by this wall - # these are the parts of the maze to the left/right of a vertical wall - # or the top/bottom of a horizontal wall - ngaps = max(1, ngaps // 2) + # + # SPLITTING + # - if vertical: - new = [ - ((xmin, ymin), (pos - 1, ymax), ngaps, not vertical), - ((pos + 1, ymin), (xmax, ymax), ngaps, not vertical), - ] - else: - new = [ - ((xmin, ymin), (xmax, pos - 1), ngaps, not vertical), - ((xmin, pos + 1), (xmax, ymax), ngaps, not vertical), - ] + # we split the partition in 2, so we divide the number of gaps by 2; + # ensure a connected maze with a minimum of 1 sampled gap + ngaps = max(1, ngaps // 2) - # queue the new partitions next; - # ensures maze stability - partitions.insert(p + 1, new[1]) - partitions.insert(p + 1, new[0]) + # define new partitions inscribed in the current one, split by the wall; + # this definition is true for vertical and horizontal walls + new = ( + # top/left + (pmin, wmax, ngaps, not vertical), + # bottom/right + (wmin, pmax, ngaps, not vertical), + ) - # increase the partition index - p += 1 + # queue the new partitions next + # + # TODO: + # when we drop compatibility with numpy mazes, remove inversion + partitions.extend(new[::-1]) return walls @@ -252,53 +289,80 @@ def generate_half_maze(width, height, ngaps_center, bots_pos, rng=None): # use binary space partitioning rng = default_rng(rng) - # outer walls are top, bottom, left and right edge - walls = {(x, 0) for x in range(width)} | \ - {(x, height-1) for x in range(width)} | \ - {(0, y) for y in range(height)} | \ - {(width-1, y) for y in range(height)} + # outer walls except the border + walls = ( + # top + {(x, 0) for x in range(width // 2)} + # bottom + | {(x, height - 1) for x in range(width // 2)} + # left + | {(0, y) for y in range(height)} + ) - # Generate a wall with gaps at the border between the two homezones + # + # BORDER SAMPLING + # + # generate a wall with gaps at the border between the two homezones # in the left side of the maze - # TODO: when we decide to break backward compatibility with the numpy version - # of create maze, this part can be delegated directly to generate_walls and - # then we need to rewrite mirror to mirror a set of coordinates around the center - # by discarding the lower part of the border + # start with a full wall at the left side of the border + pos = width // 2 - 1 + border = {(pos, y) for y in range(1, height - 1)} - # Let us start with a full wall at the left side of the border - x_wall = width//2 - 1 - wall = {(x_wall, y) for y in range(1, height - 1)} - - # possible locations for gaps + # possible locations for gaps; # these gaps need to be symmetric around the center - # TODO: when we decide to break compatibility with the numpy version of - # create_maze we can rewrite this. See generate_walls for an example + # + # TODO: + # when we drop compatibility with numpy mazes, this might be rewritten to + # sample wall segments to keep with k = len(wall) - ngaps ymax = (height - 2) // 2 candidates = list(range(ymax)) - rng.shuffle(candidates) + candidates = sample(candidates, ngaps_center//2, rng) + + # save gaps and edges for chamber finding + gaps = set() + edges = set() + + # remove gaps from top and mirrored from bottom + for y in candidates: + upper = (pos, y + 1) + lower = (pos, height - 2 - y) + + # add both gaps + gaps.add(upper) + gaps.add(lower) + + # edges between those gaps which would be connected after mirroring + edges.add((upper, lower)) - for gap in candidates[:ngaps_center//2]: - wall.remove((x_wall, gap+1)) - wall.remove((x_wall, ymax*2 - gap)) + # remove gaps from border + border -= gaps - walls |= wall - partition = ((1, 1), (x_wall - 1, ymax * 2)) + # collect the border into the global wall set + walls |= border - walls = add_wall_and_split( - partition, + # + # BINARY SPACE PARTITIONING + # + + # define the left homezone as the first partition to split + pmin = (0, 0) + pmax = (pos, height - 1) + + # run the binary space partitioning + walls = add_inner_walls( + pmin, + pmax, walls, ngaps_center // 2, vertical=False, rng=rng, ) - # make space for the pacmen: - for bot in bots_pos: - if bot in walls: - walls.remove(bot) + # make space for the pacmen + walls -= bots_pos - return walls + return walls, gaps, edges def generate_maze(trapped_food=10, total_food=30, width=32, height=16, rng=None): @@ -316,23 +380,37 @@ def generate_maze(trapped_food=10, total_food=30, width=32, height=16, rng=None) # generate a full maze, but only the left half is filled with random walls # this allows us to cut the execution time in two, because the following # graph operations are quite expensive - pacmen_pos = set([(1, height - 3), (1, height - 2)]) - walls = generate_half_maze(width, height, height//2, pacmen_pos, rng=rng) - - ### TODO: hide the chamber_finding in another function, create the graph with - # a wall on the right border + 1, so that find chambers works reliably and - # we can get rid of the {.... if tile[0] < border} in the following - # also, improve find_trapped_tiles so that it does not use x and width, but just - # requires two sets of nodes representing the left and the right of the border - # and then the main chambers is that one that has a non-empty intersection - # with both. - - # transform to graph to find dead ends and chambers for food distribution - # IMPORTANT: we have to include one column of the right border in the graph - # generation, or our algorithm to find chambers would get confused - # Note: this only works because in the right side of the maze we have no walls - # except for the surrounding ones. - graph = walls_to_graph(walls, shape=(width//2+1, height)) + + # define pacmen positions + pacmen_pos = {(1, height - 3), (1, height - 2)} + + # generate a half maze with half of the border being gaps + walls, gaps, edges = generate_half_maze(width, height, height // 2, pacmen_pos, rng=rng) + + # create a graph representing connections between free tiles + graph = walls_to_graph(walls, shape=(width // 2, height)) + + # emulate the right maze side by wiring up pairs of left border gaps which + # would be connected after mirroring: + # + # ############ + # # ───┐ + # # # │ + # # ──┐│ + # # # ││ + # # ─┐││ + # # ─┘││ + # # # ││ + # # ──┘│ + # # # │ + # # ───┘ + # ############ + # + # motivation: mitigate border gaps being detected as individual chambers, + # which would make it impossible to detect the main chamber + # assumption: border gaps are sampled centrosymmetric with always a + # wall segment in the middle on odd heights + graph.add_edges_from(edges) # the algorithm should actually guarantee this, but just to make sure, let's # fail if the graph is not fully connected @@ -342,24 +420,17 @@ def generate_maze(trapped_food=10, total_food=30, width=32, height=16, rng=None) # this gives us a set of tiles that are "trapped" within chambers, i.e. tunnels # with a dead-end or a section of tiles fully enclosed by walls except for a single # tile entrance - chamber_tiles, _ = find_trapped_tiles(graph, width, include_chambers=False) - - # we want to distribute the food only on the left half of the maze - # make sure that the tiles available for food distribution do not include - # those right on the border of the homezone - # also, no food on the initial positions of the pacmen - # IMPORTANT: the relevant chamber tiles are only those in the left side of - # the maze. By detecting chambers on only half of the maze, we may still have - # spurious chambers on the right side - border = width//2 - 1 - chamber_tiles = {tile for tile in chamber_tiles if tile[0] < border} - pacmen_pos - all_tiles = {(x, y) for x in range(border) for y in range(height)} - free_tiles = all_tiles - walls - pacmen_pos - left_food = distribute_food(free_tiles, chamber_tiles, trapped_food, total_food, rng=rng) + chamber_tiles = find_trapped_tiles(graph, gaps) + + # distribute food on the half maze with excluded border gaps and + # pacmen positions + chamber_tiles -= pacmen_pos + free_tiles = graph.nodes - gaps - pacmen_pos + food = distribute_food(free_tiles, chamber_tiles, trapped_food, total_food, rng=rng) # get the full maze with all walls and food by mirroring the left half - food = mirror(left_food, width, height) - walls = mirror(walls, width, height) + food |= mirror(food, width, height) + walls |= mirror(walls, width, height) layout = { "walls" : tuple(sorted(walls)), "food" : sorted(food), "bots" : [ (1, height - 3), (width - 2, 2), diff --git a/pelita/team.py b/pelita/team.py index a8dee66f1..d06007a00 100644 --- a/pelita/team.py +++ b/pelita/team.py @@ -82,10 +82,9 @@ def walls_to_graph(walls, shape=None): # all fields for delta_x, delta_y in [(1, 0), (0, 1)]: neighbor = (x + delta_x, y + delta_y) - # we don't need to check for getting neighbors out of the maze - # because our mazes are all surrounded by walls, i.e. our - # deltas will not put us out of the maze - if neighbor not in walls: + # check if the new node is free and within the bounds as + # the walls might not be closed + if neighbor not in walls and neighbor[0] < width and neighbor[1] < height: # this is a genuine neighbor, add an edge in the graph graph.add_edge((x, y), neighbor) return graph diff --git a/test/test_maze_generation.py b/test/test_maze_generation.py index 571f36b62..8d3dc762b 100644 --- a/test/test_maze_generation.py +++ b/test/test_maze_generation.py @@ -10,11 +10,13 @@ def layout_str_to_graph(l_str): l_dict = pl.parse_layout(l_str, strict=False) - graph = pt.walls_to_graph(l_dict['walls'], shape=l_dict['shape']) - shape = l_dict['shape'] - return graph, shape + shape = l_dict["shape"] + graph = pt.walls_to_graph(l_dict['walls'], shape=shape) + border = {(shape[0] // 2 - 1, y) for y in range(shape[1])} + gaps = border - set(l_dict["walls"]) + return graph, gaps -maze_103525239 = """ +maze_103525239_even = """ ################################ #. .....#....## . . y# # .# ######### # . # x# @@ -33,11 +35,34 @@ def layout_str_to_graph(l_str): ################################ """ -def test_generate_maze_stability(): +maze_103525239_odd = """ +################################ +#. . #.. . ## . . y# +# # ###### ### ##### #x# +# . ## # .# # . . #. .# +#. # .#....#.## # . . # # +# #. ### ## # . ## ### +# #.. . ########## # # +# # .#.# #.##.# #.#. # # +# # ########## . ..# # +### ## . # ## ### .# # +# # . . # ##.#....#. # .# +#. .# . . # #. # ## . # +#a# ##### ### ###### # # +#b . . ## . ..# . .# +################################ +""" + +def test_generate_maze_stability_even(): # we only test that we keep in returning the same maze when the random # seed is fixed, in case something changes during future porting/refactoring new_layout = mg.generate_maze(rng=SEED) - old_layout = pl.parse_layout(maze_103525239) + old_layout = pl.parse_layout(maze_103525239_even) + assert old_layout == new_layout + +def test_generate_maze_stability_odd(): + new_layout = mg.generate_maze(height=15, rng=SEED) + old_layout = pl.parse_layout(maze_103525239_odd) assert old_layout == new_layout def test_find_trapped_tiles(): @@ -50,8 +75,8 @@ def test_find_trapped_tiles(): # # ############""" - graph, shape = layout_str_to_graph(one_chamber) - one_chamber_tiles, chambers = mg.find_trapped_tiles(graph, shape[0], include_chambers=True) + graph, gaps = layout_str_to_graph(one_chamber) + one_chamber_tiles, chambers = mg.find_trapped_tiles(graph, gaps, include_chambers=True) assert len(chambers) == 1 tiles_1 = {(1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (3, 1), (3, 2)} assert one_chamber_tiles == tiles_1 @@ -65,13 +90,47 @@ def test_find_trapped_tiles(): # # # # # # ############""" - graph, shape = layout_str_to_graph(two_chambers) - two_chambers_tiles, chambers = mg.find_trapped_tiles(graph, shape[0], include_chambers=True) + graph, gaps = layout_str_to_graph(two_chambers) + two_chambers_tiles, chambers = mg.find_trapped_tiles(graph, gaps, include_chambers=True) assert len(chambers) == 2 tiles_2 = {(8,3), (8,4), (8,5), (8,6), (8,7), (9,4), (9,5), (9,6), (9,7), (10, 4), (10,5), (10,6), (10, 7) } assert two_chambers_tiles == tiles_1 | tiles_2 +def test_find_chambers_in_half_maze(): + # view of the half maze as it would be seen by `find_trapped_tiles`; + # food pellets (dots) mark the chamber tiles + maze = """################ + #... # + #### # # + # # + # # # + # ## # + # .# # + ################""" + + # the width of the mirrored maze + width = 16 + + expected_chamber_tiles = { + # top left + (1, 1), (2, 1), (3, 1), + # bottom right + (6, 6), + } + + # the chamber tiles are detected as expected + graph, gaps = layout_str_to_graph(maze) + graph.remove_nodes_from(node for node in list(graph.nodes) if node[0] >= width // 2) + sgaps = sorted(gaps) + graph.add_edges_from(zip(sgaps[:len(gaps)//2], sgaps[::-1][:len(gaps)//2])) + chamber_tiles = mg.find_trapped_tiles(graph, gaps) + assert chamber_tiles == expected_chamber_tiles + + # for completeness: specifically these tiles are not in a chamber + assert (7, 1) not in chamber_tiles + assert (7, 4) not in chamber_tiles + def test_distribute_food(): maze_chamber = """############ # # # @@ -81,9 +140,9 @@ def test_distribute_food(): # # ############""" - graph, shape = layout_str_to_graph(maze_chamber) + graph, gaps = layout_str_to_graph(maze_chamber) all_tiles = set(graph.nodes) - chamber_tiles, _ = mg.find_trapped_tiles(graph, shape[0], include_chambers=False) + chamber_tiles = mg.find_trapped_tiles(graph, gaps) # expected exceptions with pytest.raises(ValueError): @@ -267,7 +326,7 @@ def test_generate_maze_food(iteration): local_seed = SEED + iteration rng = Random(local_seed) - width = 10 + width = 12 height = 5 total_food = 7 trapped_food = 0 @@ -277,8 +336,8 @@ def test_generate_maze_food(iteration): assert width//2 not in x_food assert width//2 - 1 not in x_food with pytest.raises(ValueError): - # there are not enough free tiles for 8 pellets - total_food = 8 + # there are not enough free tiles for 20 pellets + total_food = 20 ld = mg.generate_maze(trapped_food, total_food, width, height, rng=rng) def test_maze_generation_roundtrip(): diff --git a/test/test_utils.py b/test/test_utils.py index bf7a8f147..83ccfc4d5 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -164,3 +164,54 @@ def test_walls_to_graph(): g = walls_to_graph(parsed['walls'], shape=(w, h)) # Number of nodes is maze size - walls assert len(g.nodes) == int(w) * int(h) - n_walls + + +def test_walls_to_graph_open(): + # tested layout: + # + # 0 1 2 + # 0 #-#-# + # 1 # + # 2 # + # 3 #-#-# + # + # no dangling nodes on the right (open) side are expected + + width = 3 + height = 4 + + walls = { + # top + (0, 0), (1, 0), (2, 0), + # bottom + (0, 3), (1, 3), (2, 3), + # left + (0, 1), (0, 2), + } + + expected_nodes = { + (1, 1), (2, 1), + (1, 2), (2, 2), + } + + # `walls_to_graph` adds edges from left to right and top to bottom + expected_edges = { + ((1, 1), (2, 1)), + ((1, 1), (1, 2)), + ((1, 2), (2, 2)), + ((2, 1), (2, 2)), + } + + # we rely on `walls_to_graph` getting the dimensions right + graph = walls_to_graph(walls) + + # nodes and edges are as expected + assert set(graph.nodes) == expected_nodes + assert set(graph.edges) == expected_edges + + # set the graph dimensions explicitely + graph = walls_to_graph(walls, shape=(width, height)) + + # we still get the same result + assert set(graph.nodes) == expected_nodes + assert set(graph.edges) == expected_edges