pacai.capture.board

  1import argparse
  2import logging
  3import random
  4import sys
  5import typing
  6
  7import pacai.core.board
  8import pacai.core.log
  9import pacai.pacman.board
 10import pacai.util.reflection
 11
 12MIN_SIZE: int = 10
 13
 14MAX_RANDOM_SIZE: int = 24
 15""" The maximum size for a maze with unspecified size. """
 16
 17MIN_MAZE_AXIS_WIDTH: int = 1
 18
 19DEFAULT_GAPS: int = 3
 20
 21DEFAULT_GAP_FACTOR: float = 0.50
 22MAX_GAP_FACTOR: float = 0.65
 23GAP_FACTOR_STDEV: float = 0.1
 24
 25MAX_PRISON_WIDTH: int = 3
 26
 27MIN_PELLETS: int = 1
 28MAX_PELLETS: int = 100
 29
 30MIN_CAPSULES: int = 0
 31MAX_CAPSULES: int = 4
 32
 33DEFAULT_NUM_AGENTS: int = 2
 34
 35class Maze:
 36    """
 37    A recursive definition of a maze.
 38
 39    Each maze has an "anchor", which is the position of its top-left (northwest) corner in it's parent.
 40    There is only one collection of markers that backs all mazes in a tree.
 41    """
 42
 43    def __init__(self,
 44            height: int, width: int,
 45            anchor: pacai.core.board.Position | None = None, root: typing.Union['Maze', None] = None,
 46            prison_width: int = 0,
 47            ) -> None:
 48        self.height: int = height
 49        """ The height of this submaze. """
 50
 51        self.width: int = width
 52        """ The width of this submaze. """
 53
 54        if (anchor is None):
 55            anchor = pacai.core.board.Position(0, 0)
 56
 57        self.anchor: pacai.core.board.Position = anchor
 58        """ The position that this maze lives at according to the root/parent maze. """
 59
 60        grid = None
 61
 62        if (root is None):
 63            root = self
 64        else:
 65            grid = root.grid
 66
 67        self.root: 'Maze' = root
 68        """
 69        The parent of this maze (the maze that this (sub)maze lives inside).
 70        The true root will have itself as a parent.
 71        """
 72
 73        if (grid is None):
 74            grid = [[pacai.core.board.MARKER_EMPTY for col in range(width)] for row in range(height)]
 75
 76        self.grid: list[list[pacai.core.board.Marker]] = grid
 77        """
 78        A 2-dimensional representation of all the markers in this maze.
 79        All mazes in this tree will share the same grid.
 80        """
 81
 82        if (len(self.grid) == 0):
 83            raise ValueError("Grid cannot have a zero height.")
 84
 85        self.submazes: list['Maze'] = []
 86        """ The submazes/children in this maze. """
 87
 88        self.prison_width: int = prison_width
 89        """
 90        The number of columns used by the "prisons".
 91        Prisons are the starting areas for each agent.
 92        This number does not include walls.
 93        """
 94
 95    def place_relative(self, row: int, col: int, marker: pacai.core.board.Marker) -> None:
 96        """ Place a marker in the grid according to the relative coordinates of this submaze. """
 97
 98        self.grid[self.anchor.row + row][self.anchor.col + col] = marker
 99
100    def is_marker_relative(self, row: int, col: int, marker: pacai.core.board.Marker) -> bool:
101        """ Check if the given marker exists at the relative coordinates of this submaze. """
102
103        true_row = self.anchor.row + row
104        true_col = self.anchor.col + col
105
106        # No markers are out-of-bounds.
107        if ((true_row < 0) or (true_row >= len(self.grid)) or (true_col < 0) or (true_col >= len(self.grid[0]))):
108            return False
109
110        return (self.grid[true_row][true_col] == marker)
111
112    def to_board(self, source: str) -> pacai.core.board.Board:
113        """
114        Create a pacai capture board from this maze.
115        This will add a border (of walls), add a mirrored copy (for the other team), and properly set agent indexes.
116        """
117
118        # Make a new grid that is big enough to include the opposing side (mirrored) and a border (wall) around the entire board.
119        # Initialize with wall markers for the boarder.
120        new_grid = [[pacai.core.board.MARKER_WALL for col in range((self.width * 2) + 2)] for row in range(self.height + 2)]
121
122        for base_row in range(self.height):
123            for base_col in range(self.width):
124                # Offset for the boarder.
125                row = base_row + 1
126                col = base_col + 1
127
128                # Copy the left side, and mirror around both axes for the right side.
129
130                copy_marker = self.grid[base_row][base_col]
131                mirror_marker = self.grid[self.height - base_row - 1][self.width - base_col - 1]
132
133                # If either marker is an agent, adjust the indexes.
134                # Even (including 0) are on the left, and odds are on the right.
135
136                if (copy_marker.is_agent()):
137                    copy_marker = pacai.core.board.Marker(str(copy_marker.get_agent_index() * 2))
138
139                if (mirror_marker.is_agent()):
140                    mirror_marker = pacai.core.board.Marker(str((mirror_marker.get_agent_index() * 2) + 1))
141
142                # Place the final markers.
143                new_grid[row][col] = copy_marker
144                new_grid[row][col + self.width] = mirror_marker
145
146        board_text = "\n".join([''.join(row) for row in new_grid])
147
148        kwargs = {
149            'strip': False,
150            'class': pacai.util.reflection.get_qualified_name(pacai.pacman.board.Board),
151        }
152
153        return pacai.core.board.load_string(source, board_text, **kwargs)
154
155    def _add_wall(self, rng: random.Random, wall_index: int, gaps: float = 1.0, vertical: bool = True) -> bool:
156        """
157        Try to add a vertical wall with gaps at the given location.
158        Return True if a wall was added, False otherwise.
159        """
160
161        if (vertical):
162            return self._add_vertical_wall(rng, wall_index, gaps = gaps)
163
164        return self._add_horizontal_wall(rng, wall_index, gaps = gaps)
165
166    def _add_vertical_wall(self, rng: random.Random, col: int, gaps: float = 1.0) -> bool:
167        """
168        Try to add a vertical wall with gaps at the given col.
169        Return True if a wall was added, False otherwise.
170        """
171
172        # Choose the specific number of gaps we are expecting.
173        gaps = int(round(min(self.height, gaps)))
174
175        # The places (rows) that we may put a wall.
176        slots = list(range(self.height))
177
178        # If there are empty spaces directly above or below our proposed wall,
179        # then don't put a wall marker directly in front of those respective spaces.
180        # This prevents us blocking entrances into our submaze.
181
182        if (self.is_marker_relative(-1, col, pacai.core.board.MARKER_EMPTY)):
183            slots.remove(0)
184
185        if (len(slots) == 0):
186            return False
187
188        if (self.is_marker_relative(self.height, col, pacai.core.board.MARKER_EMPTY)):
189            slots.remove(self.height - 1)
190
191        # If we cannot provided the requested number of gaps, then don't put down the wall.
192        if (len(slots) <= gaps):
193            return False
194
195        # Randomize where we will put our walls.
196        rng.shuffle(slots)
197
198        # Skip the first slots (these are gaps), and place the rest.
199        for row in slots[gaps:]:
200            self.place_relative(row, col, pacai.core.board.MARKER_WALL)
201
202        # By placing a wall, we have created two new submazes (on each side of the wall).
203
204        # One submaze to the left.
205        self.submazes.append(Maze(self.height, col, anchor = self.anchor, root = self.root))
206
207        # One submaze to the right.
208        anchor_offset = pacai.core.board.Position(0, col + 1)
209        self.submazes.append(Maze(self.height, (self.width - col - 1), self.anchor.add(anchor_offset), self.root))
210
211        return True
212
213    def _add_horizontal_wall(self, rng: random.Random, row: int, gaps: float = 1.0) -> bool:
214        """
215        Try to add a horizontal wall with gaps at the given row.
216        Return True if a wall was added, False otherwise.
217        """
218
219        # Choose the specific number of gaps we are expecting.
220        discrete_gaps = int(round(min(self.width, gaps)))
221
222        # The places (cols) that we may put a wall.
223        slots = list(range(self.width))
224
225        # If there are empty spaces directly to the left/right of our proposed wall,
226        # then don't put a wall marker directly in front of those respective spaces.
227        # This prevents us blocking entrances into our submaze.
228
229        if (self.is_marker_relative(row, -1, pacai.core.board.MARKER_EMPTY)):
230            slots.remove(0)
231
232        if (len(slots) == 0):
233            return False
234
235        if (self.is_marker_relative(row, self.width, pacai.core.board.MARKER_EMPTY)):
236            slots.remove(self.width - 1)
237
238        # If we cannot provided the requested number of gaps, then don't put down the wall.
239        if (len(slots) <= discrete_gaps):
240            return False
241
242        # Randomize where we will put our walls.
243        rng.shuffle(slots)
244
245        # Skip the first slots (these are gaps), and place the rest.
246        for col in slots[discrete_gaps:]:
247            self.place_relative(row, col, pacai.core.board.MARKER_WALL)
248
249        # By placing a wall, we have created two new submazes (on each side of the wall).
250
251        # One submaze above.
252        self.submazes.append(Maze(row, self.width, anchor = self.anchor, root = self.root))
253
254        # One submaze below.
255        anchor_offset = pacai.core.board.Position(row + 1, 0)
256        self.submazes.append(Maze((self.height - row - 1), self.width, self.anchor.add(anchor_offset), self.root))
257
258        return True
259
260    def build(self,
261            rng: random.Random,
262            gaps: float = DEFAULT_GAPS, gap_factor: float = DEFAULT_GAP_FACTOR,
263            vertical: bool = True,
264            max_pellets: int = MAX_PELLETS, max_capsules: int = MAX_CAPSULES,
265            num_agents: int = DEFAULT_NUM_AGENTS,
266            ) -> None:
267        """
268        Build a full maze into this maze.
269        This happens in several parts:
270         - Building a "prison" which is the starting places for a team.
271         - Building the rest of the maze in the non-prison area.
272         - Filling the non-prison area with capture objects (food, capsules).
273         - Placing agents on the board prioritizing the left columns.
274        """
275
276        self.prison_width = rng.randint(0, MAX_PRISON_WIDTH)
277
278        for prison_col in range(self.prison_width):
279            # Compute the actual column the wall for this prison column is on.
280            wall_col = (2 * (prison_col + 1)) - 1
281
282            # Mark a full vertical of walls.
283            for row in range(self.height):
284                self.place_relative(row, wall_col, pacai.core.board.MARKER_WALL)
285
286            # Make an opening at either the top or bottom.
287            if (prison_col % 2 == 0):
288                self.place_relative(0, wall_col, pacai.core.board.MARKER_EMPTY)
289            else:
290                self.place_relative(self.height - 1, wall_col, pacai.core.board.MARKER_EMPTY)
291
292        # Everything outside the prison is now a submaze.
293        # We will not make a submaze for the prison, since we don't want to edit that.
294        anchor_offset = pacai.core.board.Position(0, 2 * self.prison_width)
295        non_prison_submaze = Maze(self.height, self.width - (2 * self.prison_width), self.anchor.add(anchor_offset), self.root)
296        self.submazes.append(non_prison_submaze)
297
298        # Build the rest of the maze.
299        for submaze in self.submazes:
300            submaze._build_submaze(rng, gaps = gaps, gap_factor = gap_factor, vertical = vertical)
301
302        # Place agents.
303        self._place_agents(rng, num_agents = num_agents)
304
305        # Place capture objects.
306        non_prison_submaze._place_capture_markers(rng, max_pellets = max_pellets, max_capsules = max_capsules)
307
308    def _build_submaze(self,
309            rng: random.Random,
310            gaps: float = DEFAULT_GAPS, gap_factor: float = DEFAULT_GAP_FACTOR,
311            vertical: bool = True) -> None:
312        """
313        Recursively build a maze by making a wall (which will create 0 or two submazes),
314        and then building a maze with a different orientation in the submaze.
315        """
316
317        # Stop when there is no more room in either orientation.
318        if ((self.height <= MIN_MAZE_AXIS_WIDTH) and (self.width <= MIN_MAZE_AXIS_WIDTH)):
319            return
320
321        # Decide between vertical and horizontal walls by seeing how much space is left across the primary axis
322        # (width if vertical, height if horizontal).
323        if (vertical):
324            axis_width = self.width
325        else:
326            axis_width = self.height
327
328        # If there is not enough room on this axis, flip the orientation.
329        if (axis_width < (MIN_MAZE_AXIS_WIDTH + 2)):
330            vertical = not vertical
331            if vertical:
332                axis_width = self.width
333            else:
334                axis_width = self.height
335
336        # Add a wall to the current maze
337        wall_slots = range(1, axis_width - 1)
338
339        if (len(wall_slots) == 0):
340            return
341
342        wall_index = rng.choice(wall_slots)
343        wall_added = self._add_wall(rng, wall_index, gaps = gaps, vertical = vertical)
344
345        # If we did not add a wall, then stop.
346        if (not wall_added):
347            return
348
349        # Recursively build submazes in the opposite orientation.
350        for submaze in self.submazes:
351            submaze._build_submaze(rng,
352                    gaps = max(1, gaps * gap_factor), gap_factor = gap_factor,
353                    vertical = (not vertical))
354
355    def _place_capture_markers(self, rng: random.Random,
356            min_pellets: int = MIN_PELLETS, max_pellets: int = MAX_PELLETS,
357            min_capsules: int = MIN_CAPSULES, max_capsules: int = MAX_CAPSULES,
358            ) -> None:
359        """
360        Place capture markers/objects on the board.
361        This includes pellets and capsules, but not agents.
362        """
363
364        # Choose a number of pellets and capsules to place, and randomize the placement order.
365        num_pellets = rng.randint(min_pellets, max_pellets)
366        num_capsules = rng.randint(min_capsules, max_capsules)
367
368        markers = ([pacai.pacman.board.MARKER_PELLET] * num_pellets) + ([pacai.pacman.board.MARKER_CAPSULE] * num_capsules)
369        rng.shuffle(markers)
370
371        # Collect all the empty locations, separated into dead-ends and non-dead-ends.
372        dead_ends = []
373        non_dead_ends = []
374
375        for row in range(self.height):
376            for col in range(self.width):
377                if (not self.is_marker_relative(row, col, pacai.core.board.MARKER_EMPTY)):
378                    continue
379
380                dead_end = True
381                for offset in pacai.core.board.CARDINAL_OFFSETS.values():
382                    if (not self.is_marker_relative(row + offset.row, col + offset.col, pacai.core.board.MARKER_EMPTY)):
383                        dead_end = False
384                        break
385
386                if (dead_end):
387                    dead_ends.append((row, col))
388                else:
389                    non_dead_ends.append((row, col))
390
391        # Shuffle the filling order, making sure that dead-ends get priority.
392        rng.shuffle(dead_ends)
393        rng.shuffle(non_dead_ends)
394        locations = dead_ends + non_dead_ends
395
396        # Keep filling until we run out of objects or locations.
397        for marker in markers:
398            (row, col) = locations.pop(0)
399            self.place_relative(row, col, marker)
400
401            if (len(locations) == 0):
402                break
403
404    def _place_agents(self, rng: random.Random, num_agents: int = DEFAULT_NUM_AGENTS) -> None:
405        """
406        Place agent in this maze.
407        Start on the far left column and randomly choose empty locations.
408        If there are not enough locations for agents, move to the next column.
409        Will raise an error if not all agents can be placed.
410        """
411
412        agent_indexes = list(range(num_agents))
413
414        for col in range(self.width):
415            empty_rows = [row for row in range(self.height) if self.is_marker_relative(row, col, pacai.core.board.MARKER_EMPTY)]
416            rng.shuffle(empty_rows)
417
418            for row in empty_rows:
419                marker = pacai.core.board.Marker(str(agent_indexes.pop(0)))
420                self.place_relative(row, col, marker)
421
422                if (len(agent_indexes) == 0):
423                    return
424
425        raise ValueError("Unable to find enough empty locations to place all agents.")
426
427def generate(
428        seed: int | None = None,
429        size: int | None = None,
430        gaps: float = DEFAULT_GAPS, gap_factor: float | None = None,
431        max_pellets: int = MAX_PELLETS, max_capsules: int = MAX_CAPSULES,
432        num_agents: int = DEFAULT_NUM_AGENTS,
433        ) -> pacai.core.board.Board:
434    """
435    Generate a random capture board.
436
437    General Procedure:
438     - Create a maze for just one team.
439       - Create a starting area ("prison") for the team.
440       - Recursively create a maze in the rest of the area be making a wall and then making a maze in each new "room" created by the wall.
441     - Place agents on the maze, prioritizing the starting area.
442     - Place pellets and capsules randomly, ensuring that dead-ends are filled first.
443     - Created a mirrored (on both axes) copy of the maze to create the other team's half of the board.
444       - Re-index all agents so that evens (and zero) are on the left and odds on the right.
445
446    This process was originally created by Dan Gillick and Jie Tang as a part of
447    UC Berkley's CS188 AI project:
448    https://ai.berkeley.edu/project_overview.html
449    """
450
451    if (seed is None):
452        seed = random.randint(0, 2**64)
453
454    rng = random.Random(seed)
455
456    if (size is None):
457        size = rng.choice(range(MIN_SIZE, MAX_RANDOM_SIZE + 1, 2))
458
459    if ((size < MIN_SIZE) or (size % 2 != 0)):
460        raise ValueError(f"Found disallowed random board size of {size}. Size must be an even number >= {MIN_SIZE}.")
461
462    logging.debug("Generating a Capture board with seed %d and size %d.", seed, size)
463
464    if (gap_factor is None):
465        gap_factor = min(MAX_GAP_FACTOR, rng.gauss(DEFAULT_GAP_FACTOR, GAP_FACTOR_STDEV))
466
467    maze = Maze(size, size)
468    maze.build(rng, gaps = gaps, gap_factor = gap_factor)
469    board = maze.to_board(f"random-{seed}")
470
471    return board
472
473def main() -> int:
474    """ Randomly generate a capture board and then print it. """
475
476    parser = argparse.ArgumentParser(description = "Randomly generate a capture board.")
477    parser = pacai.core.log.set_cli_args(parser)
478
479    parser.add_argument('--seed', dest = 'seed',
480            action = 'store', type = int, default = None,
481            help = 'The random seed for this board.')
482
483    parser.add_argument('--size', dest = 'size',
484            action = 'store', type = int, default = None,
485            help = ('The size for this board.'
486                    + f" Must be even and >= {MIN_SIZE}."
487                    + f" If not specified, a random number between {MIN_SIZE} and {MAX_RANDOM_SIZE} will be chosen."))
488
489    args = parser.parse_args()
490    args = pacai.core.log.init_from_args(parser, args)
491
492    board = generate(args.seed, args.size)
493    print(board)
494
495    return 0
496
497if __name__ == '__main__':
498    sys.exit(main())
MIN_SIZE: int = 10
MAX_RANDOM_SIZE: int = 24

The maximum size for a maze with unspecified size.

MIN_MAZE_AXIS_WIDTH: int = 1
DEFAULT_GAPS: int = 3
DEFAULT_GAP_FACTOR: float = 0.5
MAX_GAP_FACTOR: float = 0.65
GAP_FACTOR_STDEV: float = 0.1
MAX_PRISON_WIDTH: int = 3
MIN_PELLETS: int = 1
MAX_PELLETS: int = 100
MIN_CAPSULES: int = 0
MAX_CAPSULES: int = 4
DEFAULT_NUM_AGENTS: int = 2
class Maze:
 36class Maze:
 37    """
 38    A recursive definition of a maze.
 39
 40    Each maze has an "anchor", which is the position of its top-left (northwest) corner in it's parent.
 41    There is only one collection of markers that backs all mazes in a tree.
 42    """
 43
 44    def __init__(self,
 45            height: int, width: int,
 46            anchor: pacai.core.board.Position | None = None, root: typing.Union['Maze', None] = None,
 47            prison_width: int = 0,
 48            ) -> None:
 49        self.height: int = height
 50        """ The height of this submaze. """
 51
 52        self.width: int = width
 53        """ The width of this submaze. """
 54
 55        if (anchor is None):
 56            anchor = pacai.core.board.Position(0, 0)
 57
 58        self.anchor: pacai.core.board.Position = anchor
 59        """ The position that this maze lives at according to the root/parent maze. """
 60
 61        grid = None
 62
 63        if (root is None):
 64            root = self
 65        else:
 66            grid = root.grid
 67
 68        self.root: 'Maze' = root
 69        """
 70        The parent of this maze (the maze that this (sub)maze lives inside).
 71        The true root will have itself as a parent.
 72        """
 73
 74        if (grid is None):
 75            grid = [[pacai.core.board.MARKER_EMPTY for col in range(width)] for row in range(height)]
 76
 77        self.grid: list[list[pacai.core.board.Marker]] = grid
 78        """
 79        A 2-dimensional representation of all the markers in this maze.
 80        All mazes in this tree will share the same grid.
 81        """
 82
 83        if (len(self.grid) == 0):
 84            raise ValueError("Grid cannot have a zero height.")
 85
 86        self.submazes: list['Maze'] = []
 87        """ The submazes/children in this maze. """
 88
 89        self.prison_width: int = prison_width
 90        """
 91        The number of columns used by the "prisons".
 92        Prisons are the starting areas for each agent.
 93        This number does not include walls.
 94        """
 95
 96    def place_relative(self, row: int, col: int, marker: pacai.core.board.Marker) -> None:
 97        """ Place a marker in the grid according to the relative coordinates of this submaze. """
 98
 99        self.grid[self.anchor.row + row][self.anchor.col + col] = marker
100
101    def is_marker_relative(self, row: int, col: int, marker: pacai.core.board.Marker) -> bool:
102        """ Check if the given marker exists at the relative coordinates of this submaze. """
103
104        true_row = self.anchor.row + row
105        true_col = self.anchor.col + col
106
107        # No markers are out-of-bounds.
108        if ((true_row < 0) or (true_row >= len(self.grid)) or (true_col < 0) or (true_col >= len(self.grid[0]))):
109            return False
110
111        return (self.grid[true_row][true_col] == marker)
112
113    def to_board(self, source: str) -> pacai.core.board.Board:
114        """
115        Create a pacai capture board from this maze.
116        This will add a border (of walls), add a mirrored copy (for the other team), and properly set agent indexes.
117        """
118
119        # Make a new grid that is big enough to include the opposing side (mirrored) and a border (wall) around the entire board.
120        # Initialize with wall markers for the boarder.
121        new_grid = [[pacai.core.board.MARKER_WALL for col in range((self.width * 2) + 2)] for row in range(self.height + 2)]
122
123        for base_row in range(self.height):
124            for base_col in range(self.width):
125                # Offset for the boarder.
126                row = base_row + 1
127                col = base_col + 1
128
129                # Copy the left side, and mirror around both axes for the right side.
130
131                copy_marker = self.grid[base_row][base_col]
132                mirror_marker = self.grid[self.height - base_row - 1][self.width - base_col - 1]
133
134                # If either marker is an agent, adjust the indexes.
135                # Even (including 0) are on the left, and odds are on the right.
136
137                if (copy_marker.is_agent()):
138                    copy_marker = pacai.core.board.Marker(str(copy_marker.get_agent_index() * 2))
139
140                if (mirror_marker.is_agent()):
141                    mirror_marker = pacai.core.board.Marker(str((mirror_marker.get_agent_index() * 2) + 1))
142
143                # Place the final markers.
144                new_grid[row][col] = copy_marker
145                new_grid[row][col + self.width] = mirror_marker
146
147        board_text = "\n".join([''.join(row) for row in new_grid])
148
149        kwargs = {
150            'strip': False,
151            'class': pacai.util.reflection.get_qualified_name(pacai.pacman.board.Board),
152        }
153
154        return pacai.core.board.load_string(source, board_text, **kwargs)
155
156    def _add_wall(self, rng: random.Random, wall_index: int, gaps: float = 1.0, vertical: bool = True) -> bool:
157        """
158        Try to add a vertical wall with gaps at the given location.
159        Return True if a wall was added, False otherwise.
160        """
161
162        if (vertical):
163            return self._add_vertical_wall(rng, wall_index, gaps = gaps)
164
165        return self._add_horizontal_wall(rng, wall_index, gaps = gaps)
166
167    def _add_vertical_wall(self, rng: random.Random, col: int, gaps: float = 1.0) -> bool:
168        """
169        Try to add a vertical wall with gaps at the given col.
170        Return True if a wall was added, False otherwise.
171        """
172
173        # Choose the specific number of gaps we are expecting.
174        gaps = int(round(min(self.height, gaps)))
175
176        # The places (rows) that we may put a wall.
177        slots = list(range(self.height))
178
179        # If there are empty spaces directly above or below our proposed wall,
180        # then don't put a wall marker directly in front of those respective spaces.
181        # This prevents us blocking entrances into our submaze.
182
183        if (self.is_marker_relative(-1, col, pacai.core.board.MARKER_EMPTY)):
184            slots.remove(0)
185
186        if (len(slots) == 0):
187            return False
188
189        if (self.is_marker_relative(self.height, col, pacai.core.board.MARKER_EMPTY)):
190            slots.remove(self.height - 1)
191
192        # If we cannot provided the requested number of gaps, then don't put down the wall.
193        if (len(slots) <= gaps):
194            return False
195
196        # Randomize where we will put our walls.
197        rng.shuffle(slots)
198
199        # Skip the first slots (these are gaps), and place the rest.
200        for row in slots[gaps:]:
201            self.place_relative(row, col, pacai.core.board.MARKER_WALL)
202
203        # By placing a wall, we have created two new submazes (on each side of the wall).
204
205        # One submaze to the left.
206        self.submazes.append(Maze(self.height, col, anchor = self.anchor, root = self.root))
207
208        # One submaze to the right.
209        anchor_offset = pacai.core.board.Position(0, col + 1)
210        self.submazes.append(Maze(self.height, (self.width - col - 1), self.anchor.add(anchor_offset), self.root))
211
212        return True
213
214    def _add_horizontal_wall(self, rng: random.Random, row: int, gaps: float = 1.0) -> bool:
215        """
216        Try to add a horizontal wall with gaps at the given row.
217        Return True if a wall was added, False otherwise.
218        """
219
220        # Choose the specific number of gaps we are expecting.
221        discrete_gaps = int(round(min(self.width, gaps)))
222
223        # The places (cols) that we may put a wall.
224        slots = list(range(self.width))
225
226        # If there are empty spaces directly to the left/right of our proposed wall,
227        # then don't put a wall marker directly in front of those respective spaces.
228        # This prevents us blocking entrances into our submaze.
229
230        if (self.is_marker_relative(row, -1, pacai.core.board.MARKER_EMPTY)):
231            slots.remove(0)
232
233        if (len(slots) == 0):
234            return False
235
236        if (self.is_marker_relative(row, self.width, pacai.core.board.MARKER_EMPTY)):
237            slots.remove(self.width - 1)
238
239        # If we cannot provided the requested number of gaps, then don't put down the wall.
240        if (len(slots) <= discrete_gaps):
241            return False
242
243        # Randomize where we will put our walls.
244        rng.shuffle(slots)
245
246        # Skip the first slots (these are gaps), and place the rest.
247        for col in slots[discrete_gaps:]:
248            self.place_relative(row, col, pacai.core.board.MARKER_WALL)
249
250        # By placing a wall, we have created two new submazes (on each side of the wall).
251
252        # One submaze above.
253        self.submazes.append(Maze(row, self.width, anchor = self.anchor, root = self.root))
254
255        # One submaze below.
256        anchor_offset = pacai.core.board.Position(row + 1, 0)
257        self.submazes.append(Maze((self.height - row - 1), self.width, self.anchor.add(anchor_offset), self.root))
258
259        return True
260
261    def build(self,
262            rng: random.Random,
263            gaps: float = DEFAULT_GAPS, gap_factor: float = DEFAULT_GAP_FACTOR,
264            vertical: bool = True,
265            max_pellets: int = MAX_PELLETS, max_capsules: int = MAX_CAPSULES,
266            num_agents: int = DEFAULT_NUM_AGENTS,
267            ) -> None:
268        """
269        Build a full maze into this maze.
270        This happens in several parts:
271         - Building a "prison" which is the starting places for a team.
272         - Building the rest of the maze in the non-prison area.
273         - Filling the non-prison area with capture objects (food, capsules).
274         - Placing agents on the board prioritizing the left columns.
275        """
276
277        self.prison_width = rng.randint(0, MAX_PRISON_WIDTH)
278
279        for prison_col in range(self.prison_width):
280            # Compute the actual column the wall for this prison column is on.
281            wall_col = (2 * (prison_col + 1)) - 1
282
283            # Mark a full vertical of walls.
284            for row in range(self.height):
285                self.place_relative(row, wall_col, pacai.core.board.MARKER_WALL)
286
287            # Make an opening at either the top or bottom.
288            if (prison_col % 2 == 0):
289                self.place_relative(0, wall_col, pacai.core.board.MARKER_EMPTY)
290            else:
291                self.place_relative(self.height - 1, wall_col, pacai.core.board.MARKER_EMPTY)
292
293        # Everything outside the prison is now a submaze.
294        # We will not make a submaze for the prison, since we don't want to edit that.
295        anchor_offset = pacai.core.board.Position(0, 2 * self.prison_width)
296        non_prison_submaze = Maze(self.height, self.width - (2 * self.prison_width), self.anchor.add(anchor_offset), self.root)
297        self.submazes.append(non_prison_submaze)
298
299        # Build the rest of the maze.
300        for submaze in self.submazes:
301            submaze._build_submaze(rng, gaps = gaps, gap_factor = gap_factor, vertical = vertical)
302
303        # Place agents.
304        self._place_agents(rng, num_agents = num_agents)
305
306        # Place capture objects.
307        non_prison_submaze._place_capture_markers(rng, max_pellets = max_pellets, max_capsules = max_capsules)
308
309    def _build_submaze(self,
310            rng: random.Random,
311            gaps: float = DEFAULT_GAPS, gap_factor: float = DEFAULT_GAP_FACTOR,
312            vertical: bool = True) -> None:
313        """
314        Recursively build a maze by making a wall (which will create 0 or two submazes),
315        and then building a maze with a different orientation in the submaze.
316        """
317
318        # Stop when there is no more room in either orientation.
319        if ((self.height <= MIN_MAZE_AXIS_WIDTH) and (self.width <= MIN_MAZE_AXIS_WIDTH)):
320            return
321
322        # Decide between vertical and horizontal walls by seeing how much space is left across the primary axis
323        # (width if vertical, height if horizontal).
324        if (vertical):
325            axis_width = self.width
326        else:
327            axis_width = self.height
328
329        # If there is not enough room on this axis, flip the orientation.
330        if (axis_width < (MIN_MAZE_AXIS_WIDTH + 2)):
331            vertical = not vertical
332            if vertical:
333                axis_width = self.width
334            else:
335                axis_width = self.height
336
337        # Add a wall to the current maze
338        wall_slots = range(1, axis_width - 1)
339
340        if (len(wall_slots) == 0):
341            return
342
343        wall_index = rng.choice(wall_slots)
344        wall_added = self._add_wall(rng, wall_index, gaps = gaps, vertical = vertical)
345
346        # If we did not add a wall, then stop.
347        if (not wall_added):
348            return
349
350        # Recursively build submazes in the opposite orientation.
351        for submaze in self.submazes:
352            submaze._build_submaze(rng,
353                    gaps = max(1, gaps * gap_factor), gap_factor = gap_factor,
354                    vertical = (not vertical))
355
356    def _place_capture_markers(self, rng: random.Random,
357            min_pellets: int = MIN_PELLETS, max_pellets: int = MAX_PELLETS,
358            min_capsules: int = MIN_CAPSULES, max_capsules: int = MAX_CAPSULES,
359            ) -> None:
360        """
361        Place capture markers/objects on the board.
362        This includes pellets and capsules, but not agents.
363        """
364
365        # Choose a number of pellets and capsules to place, and randomize the placement order.
366        num_pellets = rng.randint(min_pellets, max_pellets)
367        num_capsules = rng.randint(min_capsules, max_capsules)
368
369        markers = ([pacai.pacman.board.MARKER_PELLET] * num_pellets) + ([pacai.pacman.board.MARKER_CAPSULE] * num_capsules)
370        rng.shuffle(markers)
371
372        # Collect all the empty locations, separated into dead-ends and non-dead-ends.
373        dead_ends = []
374        non_dead_ends = []
375
376        for row in range(self.height):
377            for col in range(self.width):
378                if (not self.is_marker_relative(row, col, pacai.core.board.MARKER_EMPTY)):
379                    continue
380
381                dead_end = True
382                for offset in pacai.core.board.CARDINAL_OFFSETS.values():
383                    if (not self.is_marker_relative(row + offset.row, col + offset.col, pacai.core.board.MARKER_EMPTY)):
384                        dead_end = False
385                        break
386
387                if (dead_end):
388                    dead_ends.append((row, col))
389                else:
390                    non_dead_ends.append((row, col))
391
392        # Shuffle the filling order, making sure that dead-ends get priority.
393        rng.shuffle(dead_ends)
394        rng.shuffle(non_dead_ends)
395        locations = dead_ends + non_dead_ends
396
397        # Keep filling until we run out of objects or locations.
398        for marker in markers:
399            (row, col) = locations.pop(0)
400            self.place_relative(row, col, marker)
401
402            if (len(locations) == 0):
403                break
404
405    def _place_agents(self, rng: random.Random, num_agents: int = DEFAULT_NUM_AGENTS) -> None:
406        """
407        Place agent in this maze.
408        Start on the far left column and randomly choose empty locations.
409        If there are not enough locations for agents, move to the next column.
410        Will raise an error if not all agents can be placed.
411        """
412
413        agent_indexes = list(range(num_agents))
414
415        for col in range(self.width):
416            empty_rows = [row for row in range(self.height) if self.is_marker_relative(row, col, pacai.core.board.MARKER_EMPTY)]
417            rng.shuffle(empty_rows)
418
419            for row in empty_rows:
420                marker = pacai.core.board.Marker(str(agent_indexes.pop(0)))
421                self.place_relative(row, col, marker)
422
423                if (len(agent_indexes) == 0):
424                    return
425
426        raise ValueError("Unable to find enough empty locations to place all agents.")

A recursive definition of a maze.

Each maze has an "anchor", which is the position of its top-left (northwest) corner in it's parent. There is only one collection of markers that backs all mazes in a tree.

Maze( height: int, width: int, anchor: pacai.core.board.Position | None = None, root: Optional[Maze] = None, prison_width: int = 0)
44    def __init__(self,
45            height: int, width: int,
46            anchor: pacai.core.board.Position | None = None, root: typing.Union['Maze', None] = None,
47            prison_width: int = 0,
48            ) -> None:
49        self.height: int = height
50        """ The height of this submaze. """
51
52        self.width: int = width
53        """ The width of this submaze. """
54
55        if (anchor is None):
56            anchor = pacai.core.board.Position(0, 0)
57
58        self.anchor: pacai.core.board.Position = anchor
59        """ The position that this maze lives at according to the root/parent maze. """
60
61        grid = None
62
63        if (root is None):
64            root = self
65        else:
66            grid = root.grid
67
68        self.root: 'Maze' = root
69        """
70        The parent of this maze (the maze that this (sub)maze lives inside).
71        The true root will have itself as a parent.
72        """
73
74        if (grid is None):
75            grid = [[pacai.core.board.MARKER_EMPTY for col in range(width)] for row in range(height)]
76
77        self.grid: list[list[pacai.core.board.Marker]] = grid
78        """
79        A 2-dimensional representation of all the markers in this maze.
80        All mazes in this tree will share the same grid.
81        """
82
83        if (len(self.grid) == 0):
84            raise ValueError("Grid cannot have a zero height.")
85
86        self.submazes: list['Maze'] = []
87        """ The submazes/children in this maze. """
88
89        self.prison_width: int = prison_width
90        """
91        The number of columns used by the "prisons".
92        Prisons are the starting areas for each agent.
93        This number does not include walls.
94        """
height: int

The height of this submaze.

width: int

The width of this submaze.

The position that this maze lives at according to the root/parent maze.

root: Maze

The parent of this maze (the maze that this (sub)maze lives inside). The true root will have itself as a parent.

grid: list[list[pacai.core.board.Marker]]

A 2-dimensional representation of all the markers in this maze. All mazes in this tree will share the same grid.

submazes: list[Maze]

The submazes/children in this maze.

prison_width: int

The number of columns used by the "prisons". Prisons are the starting areas for each agent. This number does not include walls.

def place_relative(self, row: int, col: int, marker: pacai.core.board.Marker) -> None:
96    def place_relative(self, row: int, col: int, marker: pacai.core.board.Marker) -> None:
97        """ Place a marker in the grid according to the relative coordinates of this submaze. """
98
99        self.grid[self.anchor.row + row][self.anchor.col + col] = marker

Place a marker in the grid according to the relative coordinates of this submaze.

def is_marker_relative(self, row: int, col: int, marker: pacai.core.board.Marker) -> bool:
101    def is_marker_relative(self, row: int, col: int, marker: pacai.core.board.Marker) -> bool:
102        """ Check if the given marker exists at the relative coordinates of this submaze. """
103
104        true_row = self.anchor.row + row
105        true_col = self.anchor.col + col
106
107        # No markers are out-of-bounds.
108        if ((true_row < 0) or (true_row >= len(self.grid)) or (true_col < 0) or (true_col >= len(self.grid[0]))):
109            return False
110
111        return (self.grid[true_row][true_col] == marker)

Check if the given marker exists at the relative coordinates of this submaze.

def to_board(self, source: str) -> pacai.core.board.Board:
113    def to_board(self, source: str) -> pacai.core.board.Board:
114        """
115        Create a pacai capture board from this maze.
116        This will add a border (of walls), add a mirrored copy (for the other team), and properly set agent indexes.
117        """
118
119        # Make a new grid that is big enough to include the opposing side (mirrored) and a border (wall) around the entire board.
120        # Initialize with wall markers for the boarder.
121        new_grid = [[pacai.core.board.MARKER_WALL for col in range((self.width * 2) + 2)] for row in range(self.height + 2)]
122
123        for base_row in range(self.height):
124            for base_col in range(self.width):
125                # Offset for the boarder.
126                row = base_row + 1
127                col = base_col + 1
128
129                # Copy the left side, and mirror around both axes for the right side.
130
131                copy_marker = self.grid[base_row][base_col]
132                mirror_marker = self.grid[self.height - base_row - 1][self.width - base_col - 1]
133
134                # If either marker is an agent, adjust the indexes.
135                # Even (including 0) are on the left, and odds are on the right.
136
137                if (copy_marker.is_agent()):
138                    copy_marker = pacai.core.board.Marker(str(copy_marker.get_agent_index() * 2))
139
140                if (mirror_marker.is_agent()):
141                    mirror_marker = pacai.core.board.Marker(str((mirror_marker.get_agent_index() * 2) + 1))
142
143                # Place the final markers.
144                new_grid[row][col] = copy_marker
145                new_grid[row][col + self.width] = mirror_marker
146
147        board_text = "\n".join([''.join(row) for row in new_grid])
148
149        kwargs = {
150            'strip': False,
151            'class': pacai.util.reflection.get_qualified_name(pacai.pacman.board.Board),
152        }
153
154        return pacai.core.board.load_string(source, board_text, **kwargs)

Create a pacai capture board from this maze. This will add a border (of walls), add a mirrored copy (for the other team), and properly set agent indexes.

def build( self, rng: random.Random, gaps: float = 3, gap_factor: float = 0.5, vertical: bool = True, max_pellets: int = 100, max_capsules: int = 4, num_agents: int = 2) -> None:
261    def build(self,
262            rng: random.Random,
263            gaps: float = DEFAULT_GAPS, gap_factor: float = DEFAULT_GAP_FACTOR,
264            vertical: bool = True,
265            max_pellets: int = MAX_PELLETS, max_capsules: int = MAX_CAPSULES,
266            num_agents: int = DEFAULT_NUM_AGENTS,
267            ) -> None:
268        """
269        Build a full maze into this maze.
270        This happens in several parts:
271         - Building a "prison" which is the starting places for a team.
272         - Building the rest of the maze in the non-prison area.
273         - Filling the non-prison area with capture objects (food, capsules).
274         - Placing agents on the board prioritizing the left columns.
275        """
276
277        self.prison_width = rng.randint(0, MAX_PRISON_WIDTH)
278
279        for prison_col in range(self.prison_width):
280            # Compute the actual column the wall for this prison column is on.
281            wall_col = (2 * (prison_col + 1)) - 1
282
283            # Mark a full vertical of walls.
284            for row in range(self.height):
285                self.place_relative(row, wall_col, pacai.core.board.MARKER_WALL)
286
287            # Make an opening at either the top or bottom.
288            if (prison_col % 2 == 0):
289                self.place_relative(0, wall_col, pacai.core.board.MARKER_EMPTY)
290            else:
291                self.place_relative(self.height - 1, wall_col, pacai.core.board.MARKER_EMPTY)
292
293        # Everything outside the prison is now a submaze.
294        # We will not make a submaze for the prison, since we don't want to edit that.
295        anchor_offset = pacai.core.board.Position(0, 2 * self.prison_width)
296        non_prison_submaze = Maze(self.height, self.width - (2 * self.prison_width), self.anchor.add(anchor_offset), self.root)
297        self.submazes.append(non_prison_submaze)
298
299        # Build the rest of the maze.
300        for submaze in self.submazes:
301            submaze._build_submaze(rng, gaps = gaps, gap_factor = gap_factor, vertical = vertical)
302
303        # Place agents.
304        self._place_agents(rng, num_agents = num_agents)
305
306        # Place capture objects.
307        non_prison_submaze._place_capture_markers(rng, max_pellets = max_pellets, max_capsules = max_capsules)

Build a full maze into this maze. This happens in several parts:

  • Building a "prison" which is the starting places for a team.
  • Building the rest of the maze in the non-prison area.
  • Filling the non-prison area with capture objects (food, capsules).
  • Placing agents on the board prioritizing the left columns.
def generate( seed: int | None = None, size: int | None = None, gaps: float = 3, gap_factor: float | None = None, max_pellets: int = 100, max_capsules: int = 4, num_agents: int = 2) -> pacai.core.board.Board:
428def generate(
429        seed: int | None = None,
430        size: int | None = None,
431        gaps: float = DEFAULT_GAPS, gap_factor: float | None = None,
432        max_pellets: int = MAX_PELLETS, max_capsules: int = MAX_CAPSULES,
433        num_agents: int = DEFAULT_NUM_AGENTS,
434        ) -> pacai.core.board.Board:
435    """
436    Generate a random capture board.
437
438    General Procedure:
439     - Create a maze for just one team.
440       - Create a starting area ("prison") for the team.
441       - Recursively create a maze in the rest of the area be making a wall and then making a maze in each new "room" created by the wall.
442     - Place agents on the maze, prioritizing the starting area.
443     - Place pellets and capsules randomly, ensuring that dead-ends are filled first.
444     - Created a mirrored (on both axes) copy of the maze to create the other team's half of the board.
445       - Re-index all agents so that evens (and zero) are on the left and odds on the right.
446
447    This process was originally created by Dan Gillick and Jie Tang as a part of
448    UC Berkley's CS188 AI project:
449    https://ai.berkeley.edu/project_overview.html
450    """
451
452    if (seed is None):
453        seed = random.randint(0, 2**64)
454
455    rng = random.Random(seed)
456
457    if (size is None):
458        size = rng.choice(range(MIN_SIZE, MAX_RANDOM_SIZE + 1, 2))
459
460    if ((size < MIN_SIZE) or (size % 2 != 0)):
461        raise ValueError(f"Found disallowed random board size of {size}. Size must be an even number >= {MIN_SIZE}.")
462
463    logging.debug("Generating a Capture board with seed %d and size %d.", seed, size)
464
465    if (gap_factor is None):
466        gap_factor = min(MAX_GAP_FACTOR, rng.gauss(DEFAULT_GAP_FACTOR, GAP_FACTOR_STDEV))
467
468    maze = Maze(size, size)
469    maze.build(rng, gaps = gaps, gap_factor = gap_factor)
470    board = maze.to_board(f"random-{seed}")
471
472    return board

Generate a random capture board.

General Procedure:

  • Create a maze for just one team.
    • Create a starting area ("prison") for the team.
    • Recursively create a maze in the rest of the area be making a wall and then making a maze in each new "room" created by the wall.
  • Place agents on the maze, prioritizing the starting area.
  • Place pellets and capsules randomly, ensuring that dead-ends are filled first.
  • Created a mirrored (on both axes) copy of the maze to create the other team's half of the board.
    • Re-index all agents so that evens (and zero) are on the left and odds on the right.

This process was originally created by Dan Gillick and Jie Tang as a part of UC Berkley's CS188 AI project: https://ai.berkeley.edu/project_overview.html

def main() -> int:
474def main() -> int:
475    """ Randomly generate a capture board and then print it. """
476
477    parser = argparse.ArgumentParser(description = "Randomly generate a capture board.")
478    parser = pacai.core.log.set_cli_args(parser)
479
480    parser.add_argument('--seed', dest = 'seed',
481            action = 'store', type = int, default = None,
482            help = 'The random seed for this board.')
483
484    parser.add_argument('--size', dest = 'size',
485            action = 'store', type = int, default = None,
486            help = ('The size for this board.'
487                    + f" Must be even and >= {MIN_SIZE}."
488                    + f" If not specified, a random number between {MIN_SIZE} and {MAX_RANDOM_SIZE} will be chosen."))
489
490    args = parser.parse_args()
491    args = pacai.core.log.init_from_args(parser, args)
492
493    board = generate(args.seed, args.size)
494    print(board)
495
496    return 0

Randomly generate a capture board and then print it.