pacai.core.board
1import copy 2import os 3import re 4import typing 5 6import edq.util.dirent 7import edq.util.json 8 9import pacai.core.action 10import pacai.util.reflection 11 12THIS_DIR: str = os.path.join(os.path.dirname(os.path.realpath(__file__))) 13BOARDS_DIR: str = os.path.join(THIS_DIR, '..', 'resources', 'boards') 14 15SEPARATOR_PATTERN: re.Pattern = re.compile(r'^\s*-{3,}\s*$') 16AGENT_PATTERN: re.Pattern = re.compile(r'^\d$') 17 18FILE_EXTENSION = '.board' 19 20DEFAULT_BOARD_CLASS: str = 'pacai.core.board.Board' 21 22MAX_AGENTS: int = 10 23 24MIN_HL_INTENSITY: int = 0 25MAX_HL_INTENSITY: int = 1000 26 27class Marker(str): 28 """ 29 A marker represents something that can appear on a board. 30 These are similar to a game pieces in a traditional board game (like the top hat or dog in Monopoly). 31 Another name for this class could be "Token", 32 but that term is already overloaded in Computer Science. 33 34 Markers are used throughout the life of a game to refer to that component on the board. 35 Markers may not be how a component is visually represented when the board is rendered 36 (even when a board is rendered as text), 37 but it will still be the identifier by which a piece is referenced. 38 In a standard board, agents use the identifiers 0-9 39 (and therefore there can be no more than 10 agents on a standard board). 40 """ 41 42 def is_empty(self) -> bool: 43 """ Check if the marker is for an empty location. """ 44 45 return (self == MARKER_EMPTY) 46 47 def is_wall(self) -> bool: 48 """ Check if the marker is for a wall. """ 49 50 return (self == MARKER_WALL) 51 52 def is_agent(self) -> bool: 53 """ Check if the marker is for an agent. """ 54 55 return (self in AGENT_MARKERS) 56 57 def get_agent_index(self) -> int: 58 """ 59 If this marker is an agent, return its index. 60 Otherwise, return -1. 61 """ 62 63 if (not self.is_agent()): 64 raise ValueError(f"Marker value ('{self}') is not an agent index.") 65 66 return int(self) 67 68MARKER_EMPTY: Marker = Marker(' ') 69""" 70A marker for an empty location. 71Empty markers are not stored into the board when reading from a string. 72However, empty markers will be placed in grid representations of a board. 73""" 74 75MARKER_WALL: Marker = Marker('%') 76MARKER_AGENT_0: Marker = Marker('0') 77MARKER_AGENT_1: Marker = Marker('1') 78MARKER_AGENT_2: Marker = Marker('2') 79MARKER_AGENT_3: Marker = Marker('3') 80MARKER_AGENT_4: Marker = Marker('4') 81MARKER_AGENT_5: Marker = Marker('5') 82MARKER_AGENT_6: Marker = Marker('6') 83MARKER_AGENT_7: Marker = Marker('7') 84MARKER_AGENT_8: Marker = Marker('8') 85MARKER_AGENT_9: Marker = Marker('9') 86 87AGENT_MARKERS: set[Marker] = { 88 MARKER_AGENT_0, 89 MARKER_AGENT_1, 90 MARKER_AGENT_2, 91 MARKER_AGENT_3, 92 MARKER_AGENT_4, 93 MARKER_AGENT_5, 94 MARKER_AGENT_6, 95 MARKER_AGENT_7, 96 MARKER_AGENT_8, 97 MARKER_AGENT_9, 98} 99 100BASE_MARKERS: dict[str, Marker] = { 101 MARKER_EMPTY: MARKER_EMPTY, 102 MARKER_WALL: MARKER_WALL, 103} 104 105for agent_marker in AGENT_MARKERS: 106 BASE_MARKERS[agent_marker] = agent_marker 107 108class Position(edq.util.json.DictConverter): 109 """ 110 An immutable 2-dimension location 111 representing row/y/height/y-offset and col/x/width/x-offset. 112 """ 113 114 ROW_INDEX: int = 0 115 COL_INDEX: int = 1 116 117 MAX_SIZE: int = 1000000 118 119 def __init__(self, row: int, col: int) -> None: 120 if ((abs(row) > Position.MAX_SIZE) or (abs(col) > Position.MAX_SIZE)): 121 raise ValueError(f"Dimensions {(row, col)} is greater than max of {(Position.MAX_SIZE, Position.MAX_SIZE)}.") 122 123 self._row = row 124 """ The row/y/height of this position. """ 125 126 self._col = col 127 """ The col/x/width of this position. """ 128 129 self._hash: int = (row * Position.MAX_SIZE) + col 130 """ 131 Cache the hash value to speed up checks. 132 This hash value is accurate as long as the board dimensions are under one million. 133 """ 134 135 @property 136 def row(self) -> int: 137 """ Get this position's row. """ 138 139 return self._row 140 141 @property 142 def col(self) -> int: 143 """ Get this position's col. """ 144 145 return self._col 146 147 def add(self, other: 'Position') -> 'Position': 148 """ 149 Add another position (offset) to this one and return the result. 150 """ 151 152 return Position(self._row + other._row, self._col + other._col) 153 154 def apply_action(self, action: pacai.core.action.Action) -> 'Position': 155 """ 156 Return a position that represents moving in the cardinal direction indicated by the given action. 157 If the action is not one of the cardinal actions (N/E/S/W), 158 then the same position will be returned. 159 """ 160 161 offset = CARDINAL_OFFSETS.get(action, None) 162 if (offset is None): 163 return self 164 165 return self.add(offset) 166 167 def __lt__(self, other: 'Position') -> bool: # type: ignore[override] 168 return (self._hash < other._hash) 169 170 def __eq__(self, other: object) -> bool: 171 if (not isinstance(other, Position)): 172 return False 173 174 return (self._hash == other._hash) 175 176 def __hash__(self) -> int: 177 return self._hash 178 179 def __str__(self) -> str: 180 return f"({self._row}, {self._col})" 181 182 def __repr__(self) -> str: 183 return str(self) 184 185 def to_dict(self) -> dict[str, typing.Any]: 186 return { 187 'row': self._row, 188 'col': self._col, 189 } 190 191 @classmethod 192 def from_dict(cls, data: dict[str, typing.Any]) -> typing.Any: 193 return Position(row = data['row'], col = data['col']) 194 195CARDINAL_OFFSETS: dict[pacai.core.action.Action, Position] = { 196 pacai.core.action.NORTH: Position(-1, 0), 197 pacai.core.action.EAST: Position(0, 1), 198 pacai.core.action.SOUTH: Position(1, 0), 199 pacai.core.action.WEST: Position(0, -1), 200} 201 202class Highlight(edq.util.json.DictConverter): 203 """ 204 A class representing a request to highlight/emphasize a position on the board. 205 """ 206 207 def __init__(self, 208 position: Position, 209 intensity: int | float | None, 210 ) -> None: 211 self.position = position 212 """ The position of this highlight. """ 213 214 if (isinstance(intensity, float)): 215 if ((intensity < 0.0) or (intensity > 1.0)): 216 raise ValueError(f"Floating point highlight intensity must be in [0.0, 1.0], found: {intensity}.") 217 218 intensity = int(intensity * MAX_HL_INTENSITY) 219 220 if (isinstance(intensity, int)): 221 if ((intensity < MIN_HL_INTENSITY) or (intensity > MAX_HL_INTENSITY)): 222 raise ValueError(f"Integer highlight intensity must be in [MIN_HL_INTENSITY, MAX_HL_INTENSITY], found: {intensity}.") 223 224 self.intensity: int | None = intensity 225 """ 226 The highlight intensity associated with this position, 227 or None if this highlight should be cleared. 228 """ 229 230 def get_float_intensity(self) -> float | None: 231 """ Get the highlight intensity in [0.0, 1.0]. """ 232 233 if (self.intensity is None): 234 return None 235 236 return self.intensity / MAX_HL_INTENSITY 237 238 def to_dict(self) -> dict[str, typing.Any]: 239 return { 240 'position': self.position.to_dict(), 241 'intensity': self.intensity, 242 } 243 244 @classmethod 245 def from_dict(cls, data: dict[str, typing.Any]) -> typing.Any: 246 data = { 247 'position': Position.from_dict(data['position']), 248 'intensity': data['intensity'], 249 } 250 return cls(**data) 251 252class AdjacencyString(str): 253 """ 254 A string that indicates which directions have something adjacent. 255 This string is always four characters long, 256 which each character being 'T' for true and 'F' for false. 257 The characters are ordered North, East, South, West (NESW). 258 So, 'TFTF' indicates that there are things to the north and south. 259 """ 260 261 TRUE = 'T' 262 FALSE = 'F' 263 264 NORTH_INDEX = 0 265 EAST_INDEX = 1 266 SOUTH_INDEX = 2 267 WEST_INDEX = 3 268 269 def __new__(cls, raw_text: str) -> 'AdjacencyString': 270 text = super().__new__(cls, raw_text.strip().upper()) 271 272 if (len(text) != 4): 273 raise ValueError(f"AdjacencyString must have exactly four characters, found {len(text)}.") 274 275 for char in text: 276 if (char not in {AdjacencyString.TRUE, AdjacencyString.FALSE}): 277 raise ValueError(f"AdjacencyString must only have '{AdjacencyString.TRUE}' or '{AdjacencyString.FALSE}', found '{text}'.") 278 279 return text 280 281 def north(self) -> bool: 282 """ Check if there is an adjacency to the north. """ 283 284 return (self[AdjacencyString.NORTH_INDEX] == AdjacencyString.TRUE) 285 286 def east(self) -> bool: 287 """ Check if there is an adjacency to the east. """ 288 289 return (self[AdjacencyString.EAST_INDEX] == AdjacencyString.TRUE) 290 291 def south(self) -> bool: 292 """ Check if there is an adjacency to the south. """ 293 294 return (self[AdjacencyString.SOUTH_INDEX] == AdjacencyString.TRUE) 295 296 def west(self) -> bool: 297 """ Check if there is an adjacency to the west. """ 298 299 return (self[AdjacencyString.WEST_INDEX] == AdjacencyString.TRUE) 300 301class Board(edq.util.json.DictConverter): 302 """ 303 A board represents the positional components of a game. 304 For example, a board contains the agents, walls and collectable items. 305 306 Most types of games (anything that would subclass pacai.core.game.Game) should probably 307 also subclass this to make their own type of board. 308 309 On disk, boards are represented in files that have two sections (divided by a '---' line). 310 The first section is a JSON object that holds any options for the board. 311 The second section is a textual representation of the board. 312 The specific board class (usually specified by the board options) should know how to interpret the text-based board. 313 Agents on the text-based board are numbered by their index (0-9). 314 315 Walls are treated (and stored) specially, 316 and are not expected to change once a board is created (they are considered fixed). 317 318 Callers should generally stick to the provided methods, 319 as some of the underlying data structures may be optimized for performance. 320 """ 321 322 def __init__(self, 323 source: str, 324 board_text: str | None = None, 325 markers: dict[str, Marker] | None = None, 326 additional_markers: list[str] | None = None, 327 strip: bool = True, 328 search_target: Position | dict[str, typing.Any] | None = None, 329 _height: int | None = None, 330 _width: int | None = None, 331 _walls: set[Position] | None = None, 332 _nonwall_objects: dict[Marker, set[Position]] | None = None, 333 _agent_initial_positions: dict[Marker, Position] | None = None, 334 **kwargs: typing.Any) -> None: 335 """ 336 Construct a board. 337 The board details will either be parsed from `board_text` is provided, 338 or taken directly from the given underscore arguments. 339 """ 340 341 self.source: str = source 342 """ Where this board was loaded from. """ 343 344 if (markers is None): 345 markers = BASE_MARKERS.copy() 346 347 self._markers: dict[str, Marker] = markers 348 """ Map the text for a marker to the actual marker. """ 349 350 if (additional_markers is not None): 351 for marker in additional_markers: 352 self._markers[marker] = Marker(marker) 353 354 self.height: int = -1 355 """ The height (number of rows, "y") of the board. """ 356 357 self.width: int = -1 358 """ The width (number of columns, "x") of the board. """ 359 360 self._walls: set[Position] = set() 361 """ The walls for this board. """ 362 363 self._neighbor_cache: dict[Position, list[tuple[pacai.core.action.Action, Position]]] = {} 364 """ 365 Keep a cache of all neighbor locations. 366 Note that neighbors only depend on the size of the board and walls. 367 Computing neighbors is a high-throughput activity, and therefore justifies a cache. 368 Neighbors will be shallow copied when a board is copied, 369 therefore all successor boards will share the same cache. 370 """ 371 372 self._nonwall_objects: dict[Marker, set[Position]] = {} 373 """ All the non-wall objects that appear on the board. """ 374 375 self._agent_initial_positions: dict[Marker, Position] = {} 376 """ Keep track of where each agent started. """ 377 378 if (isinstance(search_target, dict)): 379 search_target = Position.from_dict(search_target) 380 381 self.search_target: Position | None = search_target # type: ignore 382 """ Some boards (especially mazes) will have a specific positional search target. """ 383 384 self._is_shallow: bool = False 385 """ 386 Keep track of if the variable components of the board have been copied. 387 We will only make a copy of these component on a write. 388 """ 389 390 # The board text has been provided, parse the data from it. 391 if (board_text is not None): 392 height, width, all_objects, agents = self._process_text(board_text, strip = strip) 393 394 walls = set() 395 if (MARKER_WALL in all_objects): 396 walls = all_objects[MARKER_WALL] 397 del all_objects[MARKER_WALL] 398 399 self.height = height 400 self.width = width 401 self._walls = walls 402 self._nonwall_objects = all_objects 403 self._agent_initial_positions = agents 404 else: 405 # No board text has been provided, all attributes must be provided. 406 checks = [ 407 (_height, 'height'), 408 (_width, 'width'), 409 (_nonwall_objects, 'objects'), 410 (_agent_initial_positions, 'agent initial positions'), 411 ] 412 413 for (value, label) in checks: 414 if (value is None): 415 raise ValueError(f"Board {label} cannot be empty when the board text is not supplied.") 416 417 self.height = _height # type: ignore 418 self.width = _width # type: ignore 419 self._walls = _walls # type: ignore 420 self._nonwall_objects = _nonwall_objects # type: ignore 421 self._agent_initial_positions = _agent_initial_positions # type: ignore 422 423 def copy(self) -> 'Board': 424 """ Get a copy of this board. """ 425 426 # Make a shallow copy. 427 new_board = copy.copy(self) 428 429 # Mark both ourself and the copy as shallow. 430 self._is_shallow = True 431 new_board._is_shallow = True 432 433 return new_board 434 435 def _copy_on_write(self) -> None: 436 """ Copy any copy-on-write components if necessary. """ 437 438 if (not self._is_shallow): 439 return 440 441 self._nonwall_objects = {marker: positions.copy() for (marker, positions) in self._nonwall_objects.items()} 442 self._is_shallow = False 443 444 def size(self) -> int: 445 """ Get the total number of places in this board. """ 446 447 return self.height * self.width 448 449 def get_corners(self, offset: int = 0) -> tuple[Position, Position, Position, Position]: 450 """ 451 Get the corner positions of this board (ordered as NE, SE, SW, NW). 452 453 The offset is how much to move diagonally in from each corner. 454 By default, it is zero and will be exactly at each corner position. 455 However many board have a border of walls, 456 in this case an offset of 1 will return those non-border corners. 457 """ 458 459 # Add 1 for zero indexing. 460 high_offset = (1 + offset) 461 462 return ( 463 Position(offset, self.width - high_offset), 464 Position(self.height - high_offset, self.width - high_offset), 465 Position(self.height - high_offset, offset), 466 Position(offset, offset), 467 ) 468 469 def agent_count(self) -> int: 470 """ 471 Return the number of agents supported by this board. 472 This counts the number of initial position seen for agents when the board was first parsed. 473 """ 474 475 return len(self._agent_initial_positions) 476 477 def agent_indexes(self) -> list[int]: 478 """ 479 Get the indexes for all the agents supposed by this board. 480 The reported agents are agents supported by the board, not just agents currently on the board. 481 """ 482 483 agent_indexes = [] 484 for marker in self._agent_initial_positions: 485 agent_indexes.append(marker.get_agent_index()) 486 487 return agent_indexes 488 489 def get(self, position: Position) -> set[Marker]: 490 """ 491 Get all non-wall objects at the given position. 492 """ 493 494 self._check_bounds(position) 495 496 found_objects: set[Marker] = set() 497 for (marker, objects) in self._nonwall_objects.items(): 498 if position in objects: 499 found_objects.add(marker) 500 501 return found_objects 502 503 def get_marker_positions(self, marker: Marker) -> set[Position]: 504 """ Get all the non-wall positions for a specific marker. """ 505 506 return self._nonwall_objects.get(marker, set()).copy() 507 508 def get_marker_count(self, marker: Marker) -> int: 509 """ Get a count of the non-wall positions for a specific marker. """ 510 511 return len(self._nonwall_objects.get(marker, [])) 512 513 def get_walls(self) -> set[Position]: 514 """ Get all the walls. """ 515 516 return self._walls 517 518 def remove_agent(self, agent_index: int) -> None: 519 """ 520 Remove all traces of an agent from the board, 521 this includes markers and initial positions. 522 The agent's position will be replaces with an empty location. 523 """ 524 525 if ((agent_index < 0) or (agent_index >= MAX_AGENTS)): 526 return 527 528 self._copy_on_write() 529 530 marker = Marker(str(agent_index)) 531 532 if (marker in self._nonwall_objects): 533 del self._nonwall_objects[marker] 534 535 if (marker in self._agent_initial_positions): 536 del self._agent_initial_positions[marker] 537 538 def remove_marker(self, marker: Marker, position: Position) -> None: 539 """ 540 Remove the specified marker from the given position if it exists. 541 """ 542 543 self._check_bounds(position) 544 self._copy_on_write() 545 546 markers = self._nonwall_objects.get(marker) 547 if (markers is None): 548 return 549 550 markers.discard(position) 551 552 def place_marker(self, marker: Marker, position: Position) -> None: 553 """ 554 Place a marker at the given position. 555 """ 556 557 self._check_bounds(position) 558 self._copy_on_write() 559 560 if (marker not in self._nonwall_objects): 561 self._nonwall_objects[marker] = set() 562 563 self._nonwall_objects[marker].add(position) 564 565 def get_neighbors(self, position: Position) -> list[tuple[pacai.core.action.Action, Position]]: 566 """ 567 Get positions that are directly touching (via cardinal directions) the given position 568 without being inside a wall, 569 and the action it would take to get there. 570 571 Note that this is a high-throughput piece of code, and may contain optimizations. 572 """ 573 574 # Check the neighbor cache. 575 if (position in self._neighbor_cache): 576 return self._neighbor_cache[position] 577 578 neighbors = [] 579 for (action, offset) in CARDINAL_OFFSETS.items(): 580 neighbor = position.add(offset) 581 582 if (not self._check_bounds(neighbor, throw = False)): 583 continue 584 585 if (self.is_wall(neighbor)): 586 continue 587 588 neighbors.append((action, neighbor)) 589 590 # Save to the neighbor cache. 591 self._neighbor_cache[position] = neighbors 592 593 return neighbors 594 595 def get_adjacency(self, position: Position, marker: Marker) -> AdjacencyString: 596 """ 597 Look at the neighbors of the specified position to see if the given marker is adjacent in any direction. 598 An out-of-bounds position always counts as non-adjacent. 599 """ 600 601 # Get the set of positions to compare against. 602 if (marker == MARKER_WALL): 603 positions = self._walls 604 else: 605 positions = self._nonwall_objects[marker] 606 607 adjacency = [] 608 for direction in pacai.core.action.CARDINAL_DIRECTIONS: 609 neighbor = position.apply_action(direction) 610 611 adjacent = 'F' 612 if (neighbor in positions): 613 adjacent = 'T' 614 615 adjacency.append(adjacent) 616 617 return AdjacencyString(''.join(adjacency)) 618 619 def get_adjacent_walls(self, position: Position) -> AdjacencyString: 620 """ Shortcut for get_adjacency() with MARKER_WALL. """ 621 622 return self.get_adjacency(position, MARKER_WALL) 623 624 def is_empty(self, position: Position) -> bool: 625 """ Check if the given position is empty. """ 626 627 for objects in self._nonwall_objects.values(): 628 if (position in objects): 629 return False 630 631 return (position not in self._walls) 632 633 def is_marker(self, marker: Marker, position: Position) -> bool: 634 """ Check if the given position is has the target marker. """ 635 636 # Get the set of positions to compare against. 637 if (marker == MARKER_WALL): 638 positions = self._walls 639 else: 640 positions = self._nonwall_objects[marker] 641 642 return (position in positions) 643 644 def is_wall(self, position: Position) -> bool: 645 """ Check if the given position is a wall. """ 646 647 # Note that we are not using is_marker() for a slight speedup (since this is a common method). 648 return (position in self._walls) 649 650 def get_agent_position(self, agent_index: int) -> Position | None: 651 """ 652 Get the position of an agent, 653 or None if the agent is not on the board. 654 """ 655 656 marker = Marker(str(agent_index)) 657 positions = self._nonwall_objects.get(marker, set()) 658 659 if (len(positions) > 1): 660 raise ValueError(f"Found too many agent positions ({len(positions)}) for agent {marker}. There should only be one.") 661 662 for position in positions: 663 return position 664 665 return None 666 667 def get_agent_initial_position(self, agent_index: int) -> Position | None: 668 """ 669 Get the initial position of an agent, 670 or None if the agent was never on the board. 671 """ 672 673 marker = Marker(str(agent_index)) 674 return self._agent_initial_positions.get(marker, None) 675 676 def get_nonwall_string(self) -> str: 677 """ 678 Get a string representation of the non-wall objects on the board. 679 This should not be used for any performant code, 680 but provides a consistent way of identifying the "state" of the board. 681 """ 682 683 raw_nonwall_objects = [] 684 for (marker, positions) in sorted(self._nonwall_objects.items()): 685 raw_nonwall_objects.append(f"{marker}::{','.join([str(position) for position in sorted(positions)])}") 686 687 return '||'.join(raw_nonwall_objects) 688 689 def to_grid(self) -> list[list[Marker]]: 690 """ Convert this board to a 2-d grid. """ 691 692 grid = [[MARKER_EMPTY] * self.width for _ in range(self.height)] 693 694 # Place walls first. 695 for position in self._walls: 696 grid[position.row][position.col] = MARKER_WALL 697 698 # Place non-agents. 699 for (marker, positions) in self._nonwall_objects.items(): 700 if (marker.is_agent()): 701 continue 702 703 for position in positions: 704 grid[position.row][position.col] = marker 705 706 # Place agents. 707 for (marker, positions) in self._nonwall_objects.items(): 708 if (not marker.is_agent()): 709 continue 710 711 for position in positions: 712 grid[position.row][position.col] = marker 713 714 return grid 715 716 def __str__(self) -> str: 717 """ Get a rough string representation of the board. """ 718 719 grid = self.to_grid() 720 return "\n".join([''.join(row) for row in grid]) 721 722 def _check_bounds(self, position: Position, throw: bool = False) -> bool: 723 """ 724 Check if the given position is out-of-bonds for this board. 725 Return True if the position is in bounds, False otherwise. 726 If |throw| is True, then raise an exception. 727 """ 728 729 if ((position.row < 0) or (position.col < 0) or (position.row >= self.height) or (position.col >= self.width)): 730 if (throw): 731 raise ValueError(f"Position ('{str(position)}') is out-of-bounds.") 732 733 return False 734 735 return True 736 737 def to_dict(self) -> dict[str, typing.Any]: 738 all_objects = {} 739 for (marker, positions) in sorted(self._nonwall_objects.items()): 740 all_objects[str(marker)] = [position.to_dict() for position in sorted(positions)] 741 742 agent_initial_positions = {} 743 for (marker, position) in sorted(self._agent_initial_positions.items()): 744 agent_initial_positions[str(marker)] = position.to_dict() 745 746 search_target: Position | dict[str, typing.Any] | None = self.search_target 747 if (isinstance(search_target, Position)): 748 search_target = search_target.to_dict() 749 750 return { 751 'source': self.source, 752 'markers': {key: str(marker) for (key, marker) in sorted(self._markers.items())}, 753 'height': self.height, 754 'width': self.width, 755 'search_target': search_target, 756 '_walls': [position.to_dict() for position in sorted(self._walls)], 757 '_nonwall_objects': all_objects, 758 '_agent_initial_positions': agent_initial_positions, 759 } 760 761 @classmethod 762 def from_dict(cls, data: dict[str, typing.Any]) -> typing.Any: 763 all_objects: dict[Marker, set[Position]] = {} 764 for (raw_marker, raw_positions) in data['_nonwall_objects'].items(): 765 all_objects[Marker(raw_marker)] = {Position.from_dict(raw_position) for raw_position in raw_positions} 766 767 agent_initial_positions = {} 768 for (raw_marker, raw_position) in data['_agent_initial_positions'].items(): 769 agent_initial_positions[Marker(raw_marker)] = Position.from_dict(raw_position) 770 771 search_target = data.get('search_target', None) 772 if (search_target is not None): 773 search_target = Position.from_dict(search_target) 774 775 return cls( 776 source = data['source'], 777 markers = {key: Marker(marker) for (key, marker) in data['markers'].items()}, 778 search_target = search_target, 779 _height = data['height'], 780 _width = data['width'], 781 _walls = {Position.from_dict(raw_position) for raw_position in data.get('_walls', [])}, 782 _nonwall_objects = all_objects, 783 _agent_initial_positions = agent_initial_positions, 784 ) 785 786 def _process_text(self, 787 board_text: str, 788 strip: bool = True, 789 ) -> tuple[int, int, dict[Marker, set[Position]], dict[Marker, Position]]: 790 """ 791 Parse out a board from text. 792 """ 793 794 if (strip): 795 board_text = board_text.strip() 796 797 if (len(board_text) == 0): 798 raise ValueError('A board cannot be empty.') 799 800 lines = board_text.split("\n") 801 802 width: int = -1 803 all_objects: dict[Marker, set[Position]] = {} 804 agents: dict[Marker, Position] = {} 805 806 row_skip_count = 0 807 for (raw_row, line) in enumerate(lines): 808 row = raw_row - row_skip_count 809 810 if (strip): 811 line = line.strip() 812 813 raw_markers = self._split_line(line) 814 815 # Skip empty lines. 816 if (len(raw_markers) == 0): 817 row_skip_count += 1 818 continue 819 820 if (width == -1): 821 width = len(raw_markers) 822 823 if (width != len(raw_markers)): 824 raise ValueError(f"Unexpected width ({len(raw_markers)}) for row at index {row}. Expected {width}.") 825 826 for (col, raw_marker) in enumerate(raw_markers): 827 position = Position(row, col) 828 829 marker = self._translate_marker(raw_marker, position) 830 if (marker is None): 831 raise ValueError(f"Unknown marker '{raw_marker}' found at position ({row}, {col}).") 832 833 if (marker.is_empty()): 834 continue 835 836 if (marker not in all_objects): 837 all_objects[marker] = set() 838 839 all_objects[marker].add(position) 840 841 if (marker.is_agent()): 842 if (marker in agents): 843 raise ValueError(f"Duplicate agents ('{marker}') seen on board.") 844 845 agents[marker] = position 846 847 if (width <= 0): 848 raise ValueError("A board must have at least one column.") 849 850 height: int = len(lines) - row_skip_count 851 852 return height, width, all_objects, agents 853 854 def _split_line(self, line: str) -> list[str]: 855 """ 856 Split a line in the text representation of a board. 857 By default, this just splits into characters. 858 """ 859 860 return list(line) 861 862 def _translate_marker(self, text: str, position: Position) -> Marker | None: 863 """ 864 Translate the given text into the correct marker. 865 By default, this just looks up the text in self._markers. 866 """ 867 868 return self._markers.get(text, None) 869 870def load_path(path: str, **kwargs: typing.Any) -> Board: 871 """ 872 Load a board from a file. 873 If the given path does not exist, 874 try to prefix the path with the standard board directory and suffix with the standard extension. 875 """ 876 877 raw_path = path 878 879 # If the path does not exist, try the boards directory. 880 if (not os.path.exists(path)): 881 path = os.path.join(BOARDS_DIR, path) 882 883 # If this path does not have a good extension, add one. 884 if (os.path.splitext(path)[-1] != FILE_EXTENSION): 885 path = path + FILE_EXTENSION 886 887 if (not os.path.exists(path)): 888 raise ValueError(f"Could not find board, path does not exist: '{raw_path}'.") 889 890 text = edq.util.dirent.read_file(path, strip = False) 891 return load_string(raw_path, text, **kwargs) 892 893def load_string(source: str, text: str, **kwargs: typing.Any) -> Board: 894 """ Load a board from a string. """ 895 896 separator_index = -1 897 lines = text.split("\n") 898 899 for (i, line) in enumerate(lines): 900 if (SEPARATOR_PATTERN.match(line)): 901 separator_index = i 902 break 903 904 if (separator_index == -1): 905 # No separator was found. 906 options_text = '' 907 board_text = "\n".join(lines) 908 else: 909 options_text = "\n".join(lines[:separator_index]) 910 board_text = "\n".join(lines[(separator_index + 1):]) 911 912 options_text = options_text.strip() 913 if (len(options_text) == 0): 914 options = {} 915 else: 916 options = edq.util.json.loads(options_text) 917 918 options.update(kwargs) 919 920 board_class = options.get('class', DEFAULT_BOARD_CLASS) 921 return pacai.util.reflection.new_object(board_class, source, board_text, **options) # type: ignore[no-any-return] 922 923def create_empty(source: str, height: int, width: int, **kwargs: typing.Any) -> Board: 924 """ 925 Create an empty board with the given dimensions. 926 """ 927 928 text = ((MARKER_EMPTY * width) + "\n") * height 929 return load_string(source, text, strip = False)
28class Marker(str): 29 """ 30 A marker represents something that can appear on a board. 31 These are similar to a game pieces in a traditional board game (like the top hat or dog in Monopoly). 32 Another name for this class could be "Token", 33 but that term is already overloaded in Computer Science. 34 35 Markers are used throughout the life of a game to refer to that component on the board. 36 Markers may not be how a component is visually represented when the board is rendered 37 (even when a board is rendered as text), 38 but it will still be the identifier by which a piece is referenced. 39 In a standard board, agents use the identifiers 0-9 40 (and therefore there can be no more than 10 agents on a standard board). 41 """ 42 43 def is_empty(self) -> bool: 44 """ Check if the marker is for an empty location. """ 45 46 return (self == MARKER_EMPTY) 47 48 def is_wall(self) -> bool: 49 """ Check if the marker is for a wall. """ 50 51 return (self == MARKER_WALL) 52 53 def is_agent(self) -> bool: 54 """ Check if the marker is for an agent. """ 55 56 return (self in AGENT_MARKERS) 57 58 def get_agent_index(self) -> int: 59 """ 60 If this marker is an agent, return its index. 61 Otherwise, return -1. 62 """ 63 64 if (not self.is_agent()): 65 raise ValueError(f"Marker value ('{self}') is not an agent index.") 66 67 return int(self)
A marker represents something that can appear on a board. These are similar to a game pieces in a traditional board game (like the top hat or dog in Monopoly). Another name for this class could be "Token", but that term is already overloaded in Computer Science.
Markers are used throughout the life of a game to refer to that component on the board. Markers may not be how a component is visually represented when the board is rendered (even when a board is rendered as text), but it will still be the identifier by which a piece is referenced. In a standard board, agents use the identifiers 0-9 (and therefore there can be no more than 10 agents on a standard board).
43 def is_empty(self) -> bool: 44 """ Check if the marker is for an empty location. """ 45 46 return (self == MARKER_EMPTY)
Check if the marker is for an empty location.
48 def is_wall(self) -> bool: 49 """ Check if the marker is for a wall. """ 50 51 return (self == MARKER_WALL)
Check if the marker is for a wall.
53 def is_agent(self) -> bool: 54 """ Check if the marker is for an agent. """ 55 56 return (self in AGENT_MARKERS)
Check if the marker is for an agent.
58 def get_agent_index(self) -> int: 59 """ 60 If this marker is an agent, return its index. 61 Otherwise, return -1. 62 """ 63 64 if (not self.is_agent()): 65 raise ValueError(f"Marker value ('{self}') is not an agent index.") 66 67 return int(self)
If this marker is an agent, return its index. Otherwise, return -1.
A marker for an empty location. Empty markers are not stored into the board when reading from a string. However, empty markers will be placed in grid representations of a board.
109class Position(edq.util.json.DictConverter): 110 """ 111 An immutable 2-dimension location 112 representing row/y/height/y-offset and col/x/width/x-offset. 113 """ 114 115 ROW_INDEX: int = 0 116 COL_INDEX: int = 1 117 118 MAX_SIZE: int = 1000000 119 120 def __init__(self, row: int, col: int) -> None: 121 if ((abs(row) > Position.MAX_SIZE) or (abs(col) > Position.MAX_SIZE)): 122 raise ValueError(f"Dimensions {(row, col)} is greater than max of {(Position.MAX_SIZE, Position.MAX_SIZE)}.") 123 124 self._row = row 125 """ The row/y/height of this position. """ 126 127 self._col = col 128 """ The col/x/width of this position. """ 129 130 self._hash: int = (row * Position.MAX_SIZE) + col 131 """ 132 Cache the hash value to speed up checks. 133 This hash value is accurate as long as the board dimensions are under one million. 134 """ 135 136 @property 137 def row(self) -> int: 138 """ Get this position's row. """ 139 140 return self._row 141 142 @property 143 def col(self) -> int: 144 """ Get this position's col. """ 145 146 return self._col 147 148 def add(self, other: 'Position') -> 'Position': 149 """ 150 Add another position (offset) to this one and return the result. 151 """ 152 153 return Position(self._row + other._row, self._col + other._col) 154 155 def apply_action(self, action: pacai.core.action.Action) -> 'Position': 156 """ 157 Return a position that represents moving in the cardinal direction indicated by the given action. 158 If the action is not one of the cardinal actions (N/E/S/W), 159 then the same position will be returned. 160 """ 161 162 offset = CARDINAL_OFFSETS.get(action, None) 163 if (offset is None): 164 return self 165 166 return self.add(offset) 167 168 def __lt__(self, other: 'Position') -> bool: # type: ignore[override] 169 return (self._hash < other._hash) 170 171 def __eq__(self, other: object) -> bool: 172 if (not isinstance(other, Position)): 173 return False 174 175 return (self._hash == other._hash) 176 177 def __hash__(self) -> int: 178 return self._hash 179 180 def __str__(self) -> str: 181 return f"({self._row}, {self._col})" 182 183 def __repr__(self) -> str: 184 return str(self) 185 186 def to_dict(self) -> dict[str, typing.Any]: 187 return { 188 'row': self._row, 189 'col': self._col, 190 } 191 192 @classmethod 193 def from_dict(cls, data: dict[str, typing.Any]) -> typing.Any: 194 return Position(row = data['row'], col = data['col'])
An immutable 2-dimension location representing row/y/height/y-offset and col/x/width/x-offset.
120 def __init__(self, row: int, col: int) -> None: 121 if ((abs(row) > Position.MAX_SIZE) or (abs(col) > Position.MAX_SIZE)): 122 raise ValueError(f"Dimensions {(row, col)} is greater than max of {(Position.MAX_SIZE, Position.MAX_SIZE)}.") 123 124 self._row = row 125 """ The row/y/height of this position. """ 126 127 self._col = col 128 """ The col/x/width of this position. """ 129 130 self._hash: int = (row * Position.MAX_SIZE) + col 131 """ 132 Cache the hash value to speed up checks. 133 This hash value is accurate as long as the board dimensions are under one million. 134 """
136 @property 137 def row(self) -> int: 138 """ Get this position's row. """ 139 140 return self._row
Get this position's row.
142 @property 143 def col(self) -> int: 144 """ Get this position's col. """ 145 146 return self._col
Get this position's col.
148 def add(self, other: 'Position') -> 'Position': 149 """ 150 Add another position (offset) to this one and return the result. 151 """ 152 153 return Position(self._row + other._row, self._col + other._col)
Add another position (offset) to this one and return the result.
155 def apply_action(self, action: pacai.core.action.Action) -> 'Position': 156 """ 157 Return a position that represents moving in the cardinal direction indicated by the given action. 158 If the action is not one of the cardinal actions (N/E/S/W), 159 then the same position will be returned. 160 """ 161 162 offset = CARDINAL_OFFSETS.get(action, None) 163 if (offset is None): 164 return self 165 166 return self.add(offset)
Return a position that represents moving in the cardinal direction indicated by the given action. If the action is not one of the cardinal actions (N/E/S/W), then the same position will be returned.
186 def to_dict(self) -> dict[str, typing.Any]: 187 return { 188 'row': self._row, 189 'col': self._col, 190 }
Return a dict that can be used to represent this object. If the dict is passed to from_dict(), an identical object should be reconstructed.
A general (but inefficient) implementation is provided by default.
192 @classmethod 193 def from_dict(cls, data: dict[str, typing.Any]) -> typing.Any: 194 return Position(row = data['row'], col = data['col'])
Return an instance of this subclass created using the given dict. If the dict came from to_dict(), the returned object should be identical to the original.
A general (but inefficient) implementation is provided by default.
203class Highlight(edq.util.json.DictConverter): 204 """ 205 A class representing a request to highlight/emphasize a position on the board. 206 """ 207 208 def __init__(self, 209 position: Position, 210 intensity: int | float | None, 211 ) -> None: 212 self.position = position 213 """ The position of this highlight. """ 214 215 if (isinstance(intensity, float)): 216 if ((intensity < 0.0) or (intensity > 1.0)): 217 raise ValueError(f"Floating point highlight intensity must be in [0.0, 1.0], found: {intensity}.") 218 219 intensity = int(intensity * MAX_HL_INTENSITY) 220 221 if (isinstance(intensity, int)): 222 if ((intensity < MIN_HL_INTENSITY) or (intensity > MAX_HL_INTENSITY)): 223 raise ValueError(f"Integer highlight intensity must be in [MIN_HL_INTENSITY, MAX_HL_INTENSITY], found: {intensity}.") 224 225 self.intensity: int | None = intensity 226 """ 227 The highlight intensity associated with this position, 228 or None if this highlight should be cleared. 229 """ 230 231 def get_float_intensity(self) -> float | None: 232 """ Get the highlight intensity in [0.0, 1.0]. """ 233 234 if (self.intensity is None): 235 return None 236 237 return self.intensity / MAX_HL_INTENSITY 238 239 def to_dict(self) -> dict[str, typing.Any]: 240 return { 241 'position': self.position.to_dict(), 242 'intensity': self.intensity, 243 } 244 245 @classmethod 246 def from_dict(cls, data: dict[str, typing.Any]) -> typing.Any: 247 data = { 248 'position': Position.from_dict(data['position']), 249 'intensity': data['intensity'], 250 } 251 return cls(**data)
A class representing a request to highlight/emphasize a position on the board.
208 def __init__(self, 209 position: Position, 210 intensity: int | float | None, 211 ) -> None: 212 self.position = position 213 """ The position of this highlight. """ 214 215 if (isinstance(intensity, float)): 216 if ((intensity < 0.0) or (intensity > 1.0)): 217 raise ValueError(f"Floating point highlight intensity must be in [0.0, 1.0], found: {intensity}.") 218 219 intensity = int(intensity * MAX_HL_INTENSITY) 220 221 if (isinstance(intensity, int)): 222 if ((intensity < MIN_HL_INTENSITY) or (intensity > MAX_HL_INTENSITY)): 223 raise ValueError(f"Integer highlight intensity must be in [MIN_HL_INTENSITY, MAX_HL_INTENSITY], found: {intensity}.") 224 225 self.intensity: int | None = intensity 226 """ 227 The highlight intensity associated with this position, 228 or None if this highlight should be cleared. 229 """
The highlight intensity associated with this position, or None if this highlight should be cleared.
231 def get_float_intensity(self) -> float | None: 232 """ Get the highlight intensity in [0.0, 1.0]. """ 233 234 if (self.intensity is None): 235 return None 236 237 return self.intensity / MAX_HL_INTENSITY
Get the highlight intensity in [0.0, 1.0].
239 def to_dict(self) -> dict[str, typing.Any]: 240 return { 241 'position': self.position.to_dict(), 242 'intensity': self.intensity, 243 }
Return a dict that can be used to represent this object. If the dict is passed to from_dict(), an identical object should be reconstructed.
A general (but inefficient) implementation is provided by default.
245 @classmethod 246 def from_dict(cls, data: dict[str, typing.Any]) -> typing.Any: 247 data = { 248 'position': Position.from_dict(data['position']), 249 'intensity': data['intensity'], 250 } 251 return cls(**data)
Return an instance of this subclass created using the given dict. If the dict came from to_dict(), the returned object should be identical to the original.
A general (but inefficient) implementation is provided by default.
253class AdjacencyString(str): 254 """ 255 A string that indicates which directions have something adjacent. 256 This string is always four characters long, 257 which each character being 'T' for true and 'F' for false. 258 The characters are ordered North, East, South, West (NESW). 259 So, 'TFTF' indicates that there are things to the north and south. 260 """ 261 262 TRUE = 'T' 263 FALSE = 'F' 264 265 NORTH_INDEX = 0 266 EAST_INDEX = 1 267 SOUTH_INDEX = 2 268 WEST_INDEX = 3 269 270 def __new__(cls, raw_text: str) -> 'AdjacencyString': 271 text = super().__new__(cls, raw_text.strip().upper()) 272 273 if (len(text) != 4): 274 raise ValueError(f"AdjacencyString must have exactly four characters, found {len(text)}.") 275 276 for char in text: 277 if (char not in {AdjacencyString.TRUE, AdjacencyString.FALSE}): 278 raise ValueError(f"AdjacencyString must only have '{AdjacencyString.TRUE}' or '{AdjacencyString.FALSE}', found '{text}'.") 279 280 return text 281 282 def north(self) -> bool: 283 """ Check if there is an adjacency to the north. """ 284 285 return (self[AdjacencyString.NORTH_INDEX] == AdjacencyString.TRUE) 286 287 def east(self) -> bool: 288 """ Check if there is an adjacency to the east. """ 289 290 return (self[AdjacencyString.EAST_INDEX] == AdjacencyString.TRUE) 291 292 def south(self) -> bool: 293 """ Check if there is an adjacency to the south. """ 294 295 return (self[AdjacencyString.SOUTH_INDEX] == AdjacencyString.TRUE) 296 297 def west(self) -> bool: 298 """ Check if there is an adjacency to the west. """ 299 300 return (self[AdjacencyString.WEST_INDEX] == AdjacencyString.TRUE)
A string that indicates which directions have something adjacent. This string is always four characters long, which each character being 'T' for true and 'F' for false. The characters are ordered North, East, South, West (NESW). So, 'TFTF' indicates that there are things to the north and south.
282 def north(self) -> bool: 283 """ Check if there is an adjacency to the north. """ 284 285 return (self[AdjacencyString.NORTH_INDEX] == AdjacencyString.TRUE)
Check if there is an adjacency to the north.
287 def east(self) -> bool: 288 """ Check if there is an adjacency to the east. """ 289 290 return (self[AdjacencyString.EAST_INDEX] == AdjacencyString.TRUE)
Check if there is an adjacency to the east.
302class Board(edq.util.json.DictConverter): 303 """ 304 A board represents the positional components of a game. 305 For example, a board contains the agents, walls and collectable items. 306 307 Most types of games (anything that would subclass pacai.core.game.Game) should probably 308 also subclass this to make their own type of board. 309 310 On disk, boards are represented in files that have two sections (divided by a '---' line). 311 The first section is a JSON object that holds any options for the board. 312 The second section is a textual representation of the board. 313 The specific board class (usually specified by the board options) should know how to interpret the text-based board. 314 Agents on the text-based board are numbered by their index (0-9). 315 316 Walls are treated (and stored) specially, 317 and are not expected to change once a board is created (they are considered fixed). 318 319 Callers should generally stick to the provided methods, 320 as some of the underlying data structures may be optimized for performance. 321 """ 322 323 def __init__(self, 324 source: str, 325 board_text: str | None = None, 326 markers: dict[str, Marker] | None = None, 327 additional_markers: list[str] | None = None, 328 strip: bool = True, 329 search_target: Position | dict[str, typing.Any] | None = None, 330 _height: int | None = None, 331 _width: int | None = None, 332 _walls: set[Position] | None = None, 333 _nonwall_objects: dict[Marker, set[Position]] | None = None, 334 _agent_initial_positions: dict[Marker, Position] | None = None, 335 **kwargs: typing.Any) -> None: 336 """ 337 Construct a board. 338 The board details will either be parsed from `board_text` is provided, 339 or taken directly from the given underscore arguments. 340 """ 341 342 self.source: str = source 343 """ Where this board was loaded from. """ 344 345 if (markers is None): 346 markers = BASE_MARKERS.copy() 347 348 self._markers: dict[str, Marker] = markers 349 """ Map the text for a marker to the actual marker. """ 350 351 if (additional_markers is not None): 352 for marker in additional_markers: 353 self._markers[marker] = Marker(marker) 354 355 self.height: int = -1 356 """ The height (number of rows, "y") of the board. """ 357 358 self.width: int = -1 359 """ The width (number of columns, "x") of the board. """ 360 361 self._walls: set[Position] = set() 362 """ The walls for this board. """ 363 364 self._neighbor_cache: dict[Position, list[tuple[pacai.core.action.Action, Position]]] = {} 365 """ 366 Keep a cache of all neighbor locations. 367 Note that neighbors only depend on the size of the board and walls. 368 Computing neighbors is a high-throughput activity, and therefore justifies a cache. 369 Neighbors will be shallow copied when a board is copied, 370 therefore all successor boards will share the same cache. 371 """ 372 373 self._nonwall_objects: dict[Marker, set[Position]] = {} 374 """ All the non-wall objects that appear on the board. """ 375 376 self._agent_initial_positions: dict[Marker, Position] = {} 377 """ Keep track of where each agent started. """ 378 379 if (isinstance(search_target, dict)): 380 search_target = Position.from_dict(search_target) 381 382 self.search_target: Position | None = search_target # type: ignore 383 """ Some boards (especially mazes) will have a specific positional search target. """ 384 385 self._is_shallow: bool = False 386 """ 387 Keep track of if the variable components of the board have been copied. 388 We will only make a copy of these component on a write. 389 """ 390 391 # The board text has been provided, parse the data from it. 392 if (board_text is not None): 393 height, width, all_objects, agents = self._process_text(board_text, strip = strip) 394 395 walls = set() 396 if (MARKER_WALL in all_objects): 397 walls = all_objects[MARKER_WALL] 398 del all_objects[MARKER_WALL] 399 400 self.height = height 401 self.width = width 402 self._walls = walls 403 self._nonwall_objects = all_objects 404 self._agent_initial_positions = agents 405 else: 406 # No board text has been provided, all attributes must be provided. 407 checks = [ 408 (_height, 'height'), 409 (_width, 'width'), 410 (_nonwall_objects, 'objects'), 411 (_agent_initial_positions, 'agent initial positions'), 412 ] 413 414 for (value, label) in checks: 415 if (value is None): 416 raise ValueError(f"Board {label} cannot be empty when the board text is not supplied.") 417 418 self.height = _height # type: ignore 419 self.width = _width # type: ignore 420 self._walls = _walls # type: ignore 421 self._nonwall_objects = _nonwall_objects # type: ignore 422 self._agent_initial_positions = _agent_initial_positions # type: ignore 423 424 def copy(self) -> 'Board': 425 """ Get a copy of this board. """ 426 427 # Make a shallow copy. 428 new_board = copy.copy(self) 429 430 # Mark both ourself and the copy as shallow. 431 self._is_shallow = True 432 new_board._is_shallow = True 433 434 return new_board 435 436 def _copy_on_write(self) -> None: 437 """ Copy any copy-on-write components if necessary. """ 438 439 if (not self._is_shallow): 440 return 441 442 self._nonwall_objects = {marker: positions.copy() for (marker, positions) in self._nonwall_objects.items()} 443 self._is_shallow = False 444 445 def size(self) -> int: 446 """ Get the total number of places in this board. """ 447 448 return self.height * self.width 449 450 def get_corners(self, offset: int = 0) -> tuple[Position, Position, Position, Position]: 451 """ 452 Get the corner positions of this board (ordered as NE, SE, SW, NW). 453 454 The offset is how much to move diagonally in from each corner. 455 By default, it is zero and will be exactly at each corner position. 456 However many board have a border of walls, 457 in this case an offset of 1 will return those non-border corners. 458 """ 459 460 # Add 1 for zero indexing. 461 high_offset = (1 + offset) 462 463 return ( 464 Position(offset, self.width - high_offset), 465 Position(self.height - high_offset, self.width - high_offset), 466 Position(self.height - high_offset, offset), 467 Position(offset, offset), 468 ) 469 470 def agent_count(self) -> int: 471 """ 472 Return the number of agents supported by this board. 473 This counts the number of initial position seen for agents when the board was first parsed. 474 """ 475 476 return len(self._agent_initial_positions) 477 478 def agent_indexes(self) -> list[int]: 479 """ 480 Get the indexes for all the agents supposed by this board. 481 The reported agents are agents supported by the board, not just agents currently on the board. 482 """ 483 484 agent_indexes = [] 485 for marker in self._agent_initial_positions: 486 agent_indexes.append(marker.get_agent_index()) 487 488 return agent_indexes 489 490 def get(self, position: Position) -> set[Marker]: 491 """ 492 Get all non-wall objects at the given position. 493 """ 494 495 self._check_bounds(position) 496 497 found_objects: set[Marker] = set() 498 for (marker, objects) in self._nonwall_objects.items(): 499 if position in objects: 500 found_objects.add(marker) 501 502 return found_objects 503 504 def get_marker_positions(self, marker: Marker) -> set[Position]: 505 """ Get all the non-wall positions for a specific marker. """ 506 507 return self._nonwall_objects.get(marker, set()).copy() 508 509 def get_marker_count(self, marker: Marker) -> int: 510 """ Get a count of the non-wall positions for a specific marker. """ 511 512 return len(self._nonwall_objects.get(marker, [])) 513 514 def get_walls(self) -> set[Position]: 515 """ Get all the walls. """ 516 517 return self._walls 518 519 def remove_agent(self, agent_index: int) -> None: 520 """ 521 Remove all traces of an agent from the board, 522 this includes markers and initial positions. 523 The agent's position will be replaces with an empty location. 524 """ 525 526 if ((agent_index < 0) or (agent_index >= MAX_AGENTS)): 527 return 528 529 self._copy_on_write() 530 531 marker = Marker(str(agent_index)) 532 533 if (marker in self._nonwall_objects): 534 del self._nonwall_objects[marker] 535 536 if (marker in self._agent_initial_positions): 537 del self._agent_initial_positions[marker] 538 539 def remove_marker(self, marker: Marker, position: Position) -> None: 540 """ 541 Remove the specified marker from the given position if it exists. 542 """ 543 544 self._check_bounds(position) 545 self._copy_on_write() 546 547 markers = self._nonwall_objects.get(marker) 548 if (markers is None): 549 return 550 551 markers.discard(position) 552 553 def place_marker(self, marker: Marker, position: Position) -> None: 554 """ 555 Place a marker at the given position. 556 """ 557 558 self._check_bounds(position) 559 self._copy_on_write() 560 561 if (marker not in self._nonwall_objects): 562 self._nonwall_objects[marker] = set() 563 564 self._nonwall_objects[marker].add(position) 565 566 def get_neighbors(self, position: Position) -> list[tuple[pacai.core.action.Action, Position]]: 567 """ 568 Get positions that are directly touching (via cardinal directions) the given position 569 without being inside a wall, 570 and the action it would take to get there. 571 572 Note that this is a high-throughput piece of code, and may contain optimizations. 573 """ 574 575 # Check the neighbor cache. 576 if (position in self._neighbor_cache): 577 return self._neighbor_cache[position] 578 579 neighbors = [] 580 for (action, offset) in CARDINAL_OFFSETS.items(): 581 neighbor = position.add(offset) 582 583 if (not self._check_bounds(neighbor, throw = False)): 584 continue 585 586 if (self.is_wall(neighbor)): 587 continue 588 589 neighbors.append((action, neighbor)) 590 591 # Save to the neighbor cache. 592 self._neighbor_cache[position] = neighbors 593 594 return neighbors 595 596 def get_adjacency(self, position: Position, marker: Marker) -> AdjacencyString: 597 """ 598 Look at the neighbors of the specified position to see if the given marker is adjacent in any direction. 599 An out-of-bounds position always counts as non-adjacent. 600 """ 601 602 # Get the set of positions to compare against. 603 if (marker == MARKER_WALL): 604 positions = self._walls 605 else: 606 positions = self._nonwall_objects[marker] 607 608 adjacency = [] 609 for direction in pacai.core.action.CARDINAL_DIRECTIONS: 610 neighbor = position.apply_action(direction) 611 612 adjacent = 'F' 613 if (neighbor in positions): 614 adjacent = 'T' 615 616 adjacency.append(adjacent) 617 618 return AdjacencyString(''.join(adjacency)) 619 620 def get_adjacent_walls(self, position: Position) -> AdjacencyString: 621 """ Shortcut for get_adjacency() with MARKER_WALL. """ 622 623 return self.get_adjacency(position, MARKER_WALL) 624 625 def is_empty(self, position: Position) -> bool: 626 """ Check if the given position is empty. """ 627 628 for objects in self._nonwall_objects.values(): 629 if (position in objects): 630 return False 631 632 return (position not in self._walls) 633 634 def is_marker(self, marker: Marker, position: Position) -> bool: 635 """ Check if the given position is has the target marker. """ 636 637 # Get the set of positions to compare against. 638 if (marker == MARKER_WALL): 639 positions = self._walls 640 else: 641 positions = self._nonwall_objects[marker] 642 643 return (position in positions) 644 645 def is_wall(self, position: Position) -> bool: 646 """ Check if the given position is a wall. """ 647 648 # Note that we are not using is_marker() for a slight speedup (since this is a common method). 649 return (position in self._walls) 650 651 def get_agent_position(self, agent_index: int) -> Position | None: 652 """ 653 Get the position of an agent, 654 or None if the agent is not on the board. 655 """ 656 657 marker = Marker(str(agent_index)) 658 positions = self._nonwall_objects.get(marker, set()) 659 660 if (len(positions) > 1): 661 raise ValueError(f"Found too many agent positions ({len(positions)}) for agent {marker}. There should only be one.") 662 663 for position in positions: 664 return position 665 666 return None 667 668 def get_agent_initial_position(self, agent_index: int) -> Position | None: 669 """ 670 Get the initial position of an agent, 671 or None if the agent was never on the board. 672 """ 673 674 marker = Marker(str(agent_index)) 675 return self._agent_initial_positions.get(marker, None) 676 677 def get_nonwall_string(self) -> str: 678 """ 679 Get a string representation of the non-wall objects on the board. 680 This should not be used for any performant code, 681 but provides a consistent way of identifying the "state" of the board. 682 """ 683 684 raw_nonwall_objects = [] 685 for (marker, positions) in sorted(self._nonwall_objects.items()): 686 raw_nonwall_objects.append(f"{marker}::{','.join([str(position) for position in sorted(positions)])}") 687 688 return '||'.join(raw_nonwall_objects) 689 690 def to_grid(self) -> list[list[Marker]]: 691 """ Convert this board to a 2-d grid. """ 692 693 grid = [[MARKER_EMPTY] * self.width for _ in range(self.height)] 694 695 # Place walls first. 696 for position in self._walls: 697 grid[position.row][position.col] = MARKER_WALL 698 699 # Place non-agents. 700 for (marker, positions) in self._nonwall_objects.items(): 701 if (marker.is_agent()): 702 continue 703 704 for position in positions: 705 grid[position.row][position.col] = marker 706 707 # Place agents. 708 for (marker, positions) in self._nonwall_objects.items(): 709 if (not marker.is_agent()): 710 continue 711 712 for position in positions: 713 grid[position.row][position.col] = marker 714 715 return grid 716 717 def __str__(self) -> str: 718 """ Get a rough string representation of the board. """ 719 720 grid = self.to_grid() 721 return "\n".join([''.join(row) for row in grid]) 722 723 def _check_bounds(self, position: Position, throw: bool = False) -> bool: 724 """ 725 Check if the given position is out-of-bonds for this board. 726 Return True if the position is in bounds, False otherwise. 727 If |throw| is True, then raise an exception. 728 """ 729 730 if ((position.row < 0) or (position.col < 0) or (position.row >= self.height) or (position.col >= self.width)): 731 if (throw): 732 raise ValueError(f"Position ('{str(position)}') is out-of-bounds.") 733 734 return False 735 736 return True 737 738 def to_dict(self) -> dict[str, typing.Any]: 739 all_objects = {} 740 for (marker, positions) in sorted(self._nonwall_objects.items()): 741 all_objects[str(marker)] = [position.to_dict() for position in sorted(positions)] 742 743 agent_initial_positions = {} 744 for (marker, position) in sorted(self._agent_initial_positions.items()): 745 agent_initial_positions[str(marker)] = position.to_dict() 746 747 search_target: Position | dict[str, typing.Any] | None = self.search_target 748 if (isinstance(search_target, Position)): 749 search_target = search_target.to_dict() 750 751 return { 752 'source': self.source, 753 'markers': {key: str(marker) for (key, marker) in sorted(self._markers.items())}, 754 'height': self.height, 755 'width': self.width, 756 'search_target': search_target, 757 '_walls': [position.to_dict() for position in sorted(self._walls)], 758 '_nonwall_objects': all_objects, 759 '_agent_initial_positions': agent_initial_positions, 760 } 761 762 @classmethod 763 def from_dict(cls, data: dict[str, typing.Any]) -> typing.Any: 764 all_objects: dict[Marker, set[Position]] = {} 765 for (raw_marker, raw_positions) in data['_nonwall_objects'].items(): 766 all_objects[Marker(raw_marker)] = {Position.from_dict(raw_position) for raw_position in raw_positions} 767 768 agent_initial_positions = {} 769 for (raw_marker, raw_position) in data['_agent_initial_positions'].items(): 770 agent_initial_positions[Marker(raw_marker)] = Position.from_dict(raw_position) 771 772 search_target = data.get('search_target', None) 773 if (search_target is not None): 774 search_target = Position.from_dict(search_target) 775 776 return cls( 777 source = data['source'], 778 markers = {key: Marker(marker) for (key, marker) in data['markers'].items()}, 779 search_target = search_target, 780 _height = data['height'], 781 _width = data['width'], 782 _walls = {Position.from_dict(raw_position) for raw_position in data.get('_walls', [])}, 783 _nonwall_objects = all_objects, 784 _agent_initial_positions = agent_initial_positions, 785 ) 786 787 def _process_text(self, 788 board_text: str, 789 strip: bool = True, 790 ) -> tuple[int, int, dict[Marker, set[Position]], dict[Marker, Position]]: 791 """ 792 Parse out a board from text. 793 """ 794 795 if (strip): 796 board_text = board_text.strip() 797 798 if (len(board_text) == 0): 799 raise ValueError('A board cannot be empty.') 800 801 lines = board_text.split("\n") 802 803 width: int = -1 804 all_objects: dict[Marker, set[Position]] = {} 805 agents: dict[Marker, Position] = {} 806 807 row_skip_count = 0 808 for (raw_row, line) in enumerate(lines): 809 row = raw_row - row_skip_count 810 811 if (strip): 812 line = line.strip() 813 814 raw_markers = self._split_line(line) 815 816 # Skip empty lines. 817 if (len(raw_markers) == 0): 818 row_skip_count += 1 819 continue 820 821 if (width == -1): 822 width = len(raw_markers) 823 824 if (width != len(raw_markers)): 825 raise ValueError(f"Unexpected width ({len(raw_markers)}) for row at index {row}. Expected {width}.") 826 827 for (col, raw_marker) in enumerate(raw_markers): 828 position = Position(row, col) 829 830 marker = self._translate_marker(raw_marker, position) 831 if (marker is None): 832 raise ValueError(f"Unknown marker '{raw_marker}' found at position ({row}, {col}).") 833 834 if (marker.is_empty()): 835 continue 836 837 if (marker not in all_objects): 838 all_objects[marker] = set() 839 840 all_objects[marker].add(position) 841 842 if (marker.is_agent()): 843 if (marker in agents): 844 raise ValueError(f"Duplicate agents ('{marker}') seen on board.") 845 846 agents[marker] = position 847 848 if (width <= 0): 849 raise ValueError("A board must have at least one column.") 850 851 height: int = len(lines) - row_skip_count 852 853 return height, width, all_objects, agents 854 855 def _split_line(self, line: str) -> list[str]: 856 """ 857 Split a line in the text representation of a board. 858 By default, this just splits into characters. 859 """ 860 861 return list(line) 862 863 def _translate_marker(self, text: str, position: Position) -> Marker | None: 864 """ 865 Translate the given text into the correct marker. 866 By default, this just looks up the text in self._markers. 867 """ 868 869 return self._markers.get(text, None)
A board represents the positional components of a game. For example, a board contains the agents, walls and collectable items.
Most types of games (anything that would subclass pacai.core.game.Game) should probably also subclass this to make their own type of board.
On disk, boards are represented in files that have two sections (divided by a '---' line). The first section is a JSON object that holds any options for the board. The second section is a textual representation of the board. The specific board class (usually specified by the board options) should know how to interpret the text-based board. Agents on the text-based board are numbered by their index (0-9).
Walls are treated (and stored) specially, and are not expected to change once a board is created (they are considered fixed).
Callers should generally stick to the provided methods, as some of the underlying data structures may be optimized for performance.
323 def __init__(self, 324 source: str, 325 board_text: str | None = None, 326 markers: dict[str, Marker] | None = None, 327 additional_markers: list[str] | None = None, 328 strip: bool = True, 329 search_target: Position | dict[str, typing.Any] | None = None, 330 _height: int | None = None, 331 _width: int | None = None, 332 _walls: set[Position] | None = None, 333 _nonwall_objects: dict[Marker, set[Position]] | None = None, 334 _agent_initial_positions: dict[Marker, Position] | None = None, 335 **kwargs: typing.Any) -> None: 336 """ 337 Construct a board. 338 The board details will either be parsed from `board_text` is provided, 339 or taken directly from the given underscore arguments. 340 """ 341 342 self.source: str = source 343 """ Where this board was loaded from. """ 344 345 if (markers is None): 346 markers = BASE_MARKERS.copy() 347 348 self._markers: dict[str, Marker] = markers 349 """ Map the text for a marker to the actual marker. """ 350 351 if (additional_markers is not None): 352 for marker in additional_markers: 353 self._markers[marker] = Marker(marker) 354 355 self.height: int = -1 356 """ The height (number of rows, "y") of the board. """ 357 358 self.width: int = -1 359 """ The width (number of columns, "x") of the board. """ 360 361 self._walls: set[Position] = set() 362 """ The walls for this board. """ 363 364 self._neighbor_cache: dict[Position, list[tuple[pacai.core.action.Action, Position]]] = {} 365 """ 366 Keep a cache of all neighbor locations. 367 Note that neighbors only depend on the size of the board and walls. 368 Computing neighbors is a high-throughput activity, and therefore justifies a cache. 369 Neighbors will be shallow copied when a board is copied, 370 therefore all successor boards will share the same cache. 371 """ 372 373 self._nonwall_objects: dict[Marker, set[Position]] = {} 374 """ All the non-wall objects that appear on the board. """ 375 376 self._agent_initial_positions: dict[Marker, Position] = {} 377 """ Keep track of where each agent started. """ 378 379 if (isinstance(search_target, dict)): 380 search_target = Position.from_dict(search_target) 381 382 self.search_target: Position | None = search_target # type: ignore 383 """ Some boards (especially mazes) will have a specific positional search target. """ 384 385 self._is_shallow: bool = False 386 """ 387 Keep track of if the variable components of the board have been copied. 388 We will only make a copy of these component on a write. 389 """ 390 391 # The board text has been provided, parse the data from it. 392 if (board_text is not None): 393 height, width, all_objects, agents = self._process_text(board_text, strip = strip) 394 395 walls = set() 396 if (MARKER_WALL in all_objects): 397 walls = all_objects[MARKER_WALL] 398 del all_objects[MARKER_WALL] 399 400 self.height = height 401 self.width = width 402 self._walls = walls 403 self._nonwall_objects = all_objects 404 self._agent_initial_positions = agents 405 else: 406 # No board text has been provided, all attributes must be provided. 407 checks = [ 408 (_height, 'height'), 409 (_width, 'width'), 410 (_nonwall_objects, 'objects'), 411 (_agent_initial_positions, 'agent initial positions'), 412 ] 413 414 for (value, label) in checks: 415 if (value is None): 416 raise ValueError(f"Board {label} cannot be empty when the board text is not supplied.") 417 418 self.height = _height # type: ignore 419 self.width = _width # type: ignore 420 self._walls = _walls # type: ignore 421 self._nonwall_objects = _nonwall_objects # type: ignore 422 self._agent_initial_positions = _agent_initial_positions # type: ignore
Construct a board.
The board details will either be parsed from board_text is provided,
or taken directly from the given underscore arguments.
Some boards (especially mazes) will have a specific positional search target.
424 def copy(self) -> 'Board': 425 """ Get a copy of this board. """ 426 427 # Make a shallow copy. 428 new_board = copy.copy(self) 429 430 # Mark both ourself and the copy as shallow. 431 self._is_shallow = True 432 new_board._is_shallow = True 433 434 return new_board
Get a copy of this board.
445 def size(self) -> int: 446 """ Get the total number of places in this board. """ 447 448 return self.height * self.width
Get the total number of places in this board.
450 def get_corners(self, offset: int = 0) -> tuple[Position, Position, Position, Position]: 451 """ 452 Get the corner positions of this board (ordered as NE, SE, SW, NW). 453 454 The offset is how much to move diagonally in from each corner. 455 By default, it is zero and will be exactly at each corner position. 456 However many board have a border of walls, 457 in this case an offset of 1 will return those non-border corners. 458 """ 459 460 # Add 1 for zero indexing. 461 high_offset = (1 + offset) 462 463 return ( 464 Position(offset, self.width - high_offset), 465 Position(self.height - high_offset, self.width - high_offset), 466 Position(self.height - high_offset, offset), 467 Position(offset, offset), 468 )
Get the corner positions of this board (ordered as NE, SE, SW, NW).
The offset is how much to move diagonally in from each corner. By default, it is zero and will be exactly at each corner position. However many board have a border of walls, in this case an offset of 1 will return those non-border corners.
470 def agent_count(self) -> int: 471 """ 472 Return the number of agents supported by this board. 473 This counts the number of initial position seen for agents when the board was first parsed. 474 """ 475 476 return len(self._agent_initial_positions)
Return the number of agents supported by this board. This counts the number of initial position seen for agents when the board was first parsed.
478 def agent_indexes(self) -> list[int]: 479 """ 480 Get the indexes for all the agents supposed by this board. 481 The reported agents are agents supported by the board, not just agents currently on the board. 482 """ 483 484 agent_indexes = [] 485 for marker in self._agent_initial_positions: 486 agent_indexes.append(marker.get_agent_index()) 487 488 return agent_indexes
Get the indexes for all the agents supposed by this board. The reported agents are agents supported by the board, not just agents currently on the board.
490 def get(self, position: Position) -> set[Marker]: 491 """ 492 Get all non-wall objects at the given position. 493 """ 494 495 self._check_bounds(position) 496 497 found_objects: set[Marker] = set() 498 for (marker, objects) in self._nonwall_objects.items(): 499 if position in objects: 500 found_objects.add(marker) 501 502 return found_objects
Get all non-wall objects at the given position.
504 def get_marker_positions(self, marker: Marker) -> set[Position]: 505 """ Get all the non-wall positions for a specific marker. """ 506 507 return self._nonwall_objects.get(marker, set()).copy()
Get all the non-wall positions for a specific marker.
509 def get_marker_count(self, marker: Marker) -> int: 510 """ Get a count of the non-wall positions for a specific marker. """ 511 512 return len(self._nonwall_objects.get(marker, []))
Get a count of the non-wall positions for a specific marker.
519 def remove_agent(self, agent_index: int) -> None: 520 """ 521 Remove all traces of an agent from the board, 522 this includes markers and initial positions. 523 The agent's position will be replaces with an empty location. 524 """ 525 526 if ((agent_index < 0) or (agent_index >= MAX_AGENTS)): 527 return 528 529 self._copy_on_write() 530 531 marker = Marker(str(agent_index)) 532 533 if (marker in self._nonwall_objects): 534 del self._nonwall_objects[marker] 535 536 if (marker in self._agent_initial_positions): 537 del self._agent_initial_positions[marker]
Remove all traces of an agent from the board, this includes markers and initial positions. The agent's position will be replaces with an empty location.
539 def remove_marker(self, marker: Marker, position: Position) -> None: 540 """ 541 Remove the specified marker from the given position if it exists. 542 """ 543 544 self._check_bounds(position) 545 self._copy_on_write() 546 547 markers = self._nonwall_objects.get(marker) 548 if (markers is None): 549 return 550 551 markers.discard(position)
Remove the specified marker from the given position if it exists.
553 def place_marker(self, marker: Marker, position: Position) -> None: 554 """ 555 Place a marker at the given position. 556 """ 557 558 self._check_bounds(position) 559 self._copy_on_write() 560 561 if (marker not in self._nonwall_objects): 562 self._nonwall_objects[marker] = set() 563 564 self._nonwall_objects[marker].add(position)
Place a marker at the given position.
566 def get_neighbors(self, position: Position) -> list[tuple[pacai.core.action.Action, Position]]: 567 """ 568 Get positions that are directly touching (via cardinal directions) the given position 569 without being inside a wall, 570 and the action it would take to get there. 571 572 Note that this is a high-throughput piece of code, and may contain optimizations. 573 """ 574 575 # Check the neighbor cache. 576 if (position in self._neighbor_cache): 577 return self._neighbor_cache[position] 578 579 neighbors = [] 580 for (action, offset) in CARDINAL_OFFSETS.items(): 581 neighbor = position.add(offset) 582 583 if (not self._check_bounds(neighbor, throw = False)): 584 continue 585 586 if (self.is_wall(neighbor)): 587 continue 588 589 neighbors.append((action, neighbor)) 590 591 # Save to the neighbor cache. 592 self._neighbor_cache[position] = neighbors 593 594 return neighbors
Get positions that are directly touching (via cardinal directions) the given position without being inside a wall, and the action it would take to get there.
Note that this is a high-throughput piece of code, and may contain optimizations.
596 def get_adjacency(self, position: Position, marker: Marker) -> AdjacencyString: 597 """ 598 Look at the neighbors of the specified position to see if the given marker is adjacent in any direction. 599 An out-of-bounds position always counts as non-adjacent. 600 """ 601 602 # Get the set of positions to compare against. 603 if (marker == MARKER_WALL): 604 positions = self._walls 605 else: 606 positions = self._nonwall_objects[marker] 607 608 adjacency = [] 609 for direction in pacai.core.action.CARDINAL_DIRECTIONS: 610 neighbor = position.apply_action(direction) 611 612 adjacent = 'F' 613 if (neighbor in positions): 614 adjacent = 'T' 615 616 adjacency.append(adjacent) 617 618 return AdjacencyString(''.join(adjacency))
Look at the neighbors of the specified position to see if the given marker is adjacent in any direction. An out-of-bounds position always counts as non-adjacent.
620 def get_adjacent_walls(self, position: Position) -> AdjacencyString: 621 """ Shortcut for get_adjacency() with MARKER_WALL. """ 622 623 return self.get_adjacency(position, MARKER_WALL)
Shortcut for get_adjacency() with MARKER_WALL.
625 def is_empty(self, position: Position) -> bool: 626 """ Check if the given position is empty. """ 627 628 for objects in self._nonwall_objects.values(): 629 if (position in objects): 630 return False 631 632 return (position not in self._walls)
Check if the given position is empty.
634 def is_marker(self, marker: Marker, position: Position) -> bool: 635 """ Check if the given position is has the target marker. """ 636 637 # Get the set of positions to compare against. 638 if (marker == MARKER_WALL): 639 positions = self._walls 640 else: 641 positions = self._nonwall_objects[marker] 642 643 return (position in positions)
Check if the given position is has the target marker.
645 def is_wall(self, position: Position) -> bool: 646 """ Check if the given position is a wall. """ 647 648 # Note that we are not using is_marker() for a slight speedup (since this is a common method). 649 return (position in self._walls)
Check if the given position is a wall.
651 def get_agent_position(self, agent_index: int) -> Position | None: 652 """ 653 Get the position of an agent, 654 or None if the agent is not on the board. 655 """ 656 657 marker = Marker(str(agent_index)) 658 positions = self._nonwall_objects.get(marker, set()) 659 660 if (len(positions) > 1): 661 raise ValueError(f"Found too many agent positions ({len(positions)}) for agent {marker}. There should only be one.") 662 663 for position in positions: 664 return position 665 666 return None
Get the position of an agent, or None if the agent is not on the board.
668 def get_agent_initial_position(self, agent_index: int) -> Position | None: 669 """ 670 Get the initial position of an agent, 671 or None if the agent was never on the board. 672 """ 673 674 marker = Marker(str(agent_index)) 675 return self._agent_initial_positions.get(marker, None)
Get the initial position of an agent, or None if the agent was never on the board.
677 def get_nonwall_string(self) -> str: 678 """ 679 Get a string representation of the non-wall objects on the board. 680 This should not be used for any performant code, 681 but provides a consistent way of identifying the "state" of the board. 682 """ 683 684 raw_nonwall_objects = [] 685 for (marker, positions) in sorted(self._nonwall_objects.items()): 686 raw_nonwall_objects.append(f"{marker}::{','.join([str(position) for position in sorted(positions)])}") 687 688 return '||'.join(raw_nonwall_objects)
Get a string representation of the non-wall objects on the board. This should not be used for any performant code, but provides a consistent way of identifying the "state" of the board.
690 def to_grid(self) -> list[list[Marker]]: 691 """ Convert this board to a 2-d grid. """ 692 693 grid = [[MARKER_EMPTY] * self.width for _ in range(self.height)] 694 695 # Place walls first. 696 for position in self._walls: 697 grid[position.row][position.col] = MARKER_WALL 698 699 # Place non-agents. 700 for (marker, positions) in self._nonwall_objects.items(): 701 if (marker.is_agent()): 702 continue 703 704 for position in positions: 705 grid[position.row][position.col] = marker 706 707 # Place agents. 708 for (marker, positions) in self._nonwall_objects.items(): 709 if (not marker.is_agent()): 710 continue 711 712 for position in positions: 713 grid[position.row][position.col] = marker 714 715 return grid
Convert this board to a 2-d grid.
738 def to_dict(self) -> dict[str, typing.Any]: 739 all_objects = {} 740 for (marker, positions) in sorted(self._nonwall_objects.items()): 741 all_objects[str(marker)] = [position.to_dict() for position in sorted(positions)] 742 743 agent_initial_positions = {} 744 for (marker, position) in sorted(self._agent_initial_positions.items()): 745 agent_initial_positions[str(marker)] = position.to_dict() 746 747 search_target: Position | dict[str, typing.Any] | None = self.search_target 748 if (isinstance(search_target, Position)): 749 search_target = search_target.to_dict() 750 751 return { 752 'source': self.source, 753 'markers': {key: str(marker) for (key, marker) in sorted(self._markers.items())}, 754 'height': self.height, 755 'width': self.width, 756 'search_target': search_target, 757 '_walls': [position.to_dict() for position in sorted(self._walls)], 758 '_nonwall_objects': all_objects, 759 '_agent_initial_positions': agent_initial_positions, 760 }
Return a dict that can be used to represent this object. If the dict is passed to from_dict(), an identical object should be reconstructed.
A general (but inefficient) implementation is provided by default.
762 @classmethod 763 def from_dict(cls, data: dict[str, typing.Any]) -> typing.Any: 764 all_objects: dict[Marker, set[Position]] = {} 765 for (raw_marker, raw_positions) in data['_nonwall_objects'].items(): 766 all_objects[Marker(raw_marker)] = {Position.from_dict(raw_position) for raw_position in raw_positions} 767 768 agent_initial_positions = {} 769 for (raw_marker, raw_position) in data['_agent_initial_positions'].items(): 770 agent_initial_positions[Marker(raw_marker)] = Position.from_dict(raw_position) 771 772 search_target = data.get('search_target', None) 773 if (search_target is not None): 774 search_target = Position.from_dict(search_target) 775 776 return cls( 777 source = data['source'], 778 markers = {key: Marker(marker) for (key, marker) in data['markers'].items()}, 779 search_target = search_target, 780 _height = data['height'], 781 _width = data['width'], 782 _walls = {Position.from_dict(raw_position) for raw_position in data.get('_walls', [])}, 783 _nonwall_objects = all_objects, 784 _agent_initial_positions = agent_initial_positions, 785 )
Return an instance of this subclass created using the given dict. If the dict came from to_dict(), the returned object should be identical to the original.
A general (but inefficient) implementation is provided by default.
871def load_path(path: str, **kwargs: typing.Any) -> Board: 872 """ 873 Load a board from a file. 874 If the given path does not exist, 875 try to prefix the path with the standard board directory and suffix with the standard extension. 876 """ 877 878 raw_path = path 879 880 # If the path does not exist, try the boards directory. 881 if (not os.path.exists(path)): 882 path = os.path.join(BOARDS_DIR, path) 883 884 # If this path does not have a good extension, add one. 885 if (os.path.splitext(path)[-1] != FILE_EXTENSION): 886 path = path + FILE_EXTENSION 887 888 if (not os.path.exists(path)): 889 raise ValueError(f"Could not find board, path does not exist: '{raw_path}'.") 890 891 text = edq.util.dirent.read_file(path, strip = False) 892 return load_string(raw_path, text, **kwargs)
Load a board from a file. If the given path does not exist, try to prefix the path with the standard board directory and suffix with the standard extension.
894def load_string(source: str, text: str, **kwargs: typing.Any) -> Board: 895 """ Load a board from a string. """ 896 897 separator_index = -1 898 lines = text.split("\n") 899 900 for (i, line) in enumerate(lines): 901 if (SEPARATOR_PATTERN.match(line)): 902 separator_index = i 903 break 904 905 if (separator_index == -1): 906 # No separator was found. 907 options_text = '' 908 board_text = "\n".join(lines) 909 else: 910 options_text = "\n".join(lines[:separator_index]) 911 board_text = "\n".join(lines[(separator_index + 1):]) 912 913 options_text = options_text.strip() 914 if (len(options_text) == 0): 915 options = {} 916 else: 917 options = edq.util.json.loads(options_text) 918 919 options.update(kwargs) 920 921 board_class = options.get('class', DEFAULT_BOARD_CLASS) 922 return pacai.util.reflection.new_object(board_class, source, board_text, **options) # type: ignore[no-any-return]
Load a board from a string.
924def create_empty(source: str, height: int, width: int, **kwargs: typing.Any) -> Board: 925 """ 926 Create an empty board with the given dimensions. 927 """ 928 929 text = ((MARKER_EMPTY * width) + "\n") * height 930 return load_string(source, text, strip = False)
Create an empty board with the given dimensions.