pacai.core.game
1import abc 2import argparse 3import copy 4import logging 5import math 6import os 7import random 8import typing 9 10import edq.util.json 11 12import pacai.core.action 13import pacai.core.agentaction 14import pacai.core.agentinfo 15import pacai.core.isolation.level 16import pacai.core.ui 17import pacai.util.alias 18 19DEFAULT_MAX_TURNS: int = -1 20DEFAULT_AGENT_START_TIMEOUT: float = 0.0 21DEFAULT_AGENT_END_TIMEOUT: float = 0.0 22DEFAULT_AGENT_ACTION_TIMEOUT: float = 0.0 23 24DEFAULT_AGENT: str = pacai.util.alias.AGENT_RANDOM.short 25 26class GameInfo(edq.util.json.DictConverter): 27 """ 28 A simple container that holds common information about a game. 29 """ 30 31 def __init__(self, 32 board_source: str, 33 agent_infos: dict[int, pacai.core.agentinfo.AgentInfo], 34 isolation_level: pacai.core.isolation.level.Level = pacai.core.isolation.level.Level.NONE, 35 max_turns: int = DEFAULT_MAX_TURNS, 36 agent_start_timeout: float = DEFAULT_AGENT_START_TIMEOUT, 37 agent_end_timeout: float = DEFAULT_AGENT_END_TIMEOUT, 38 agent_action_timeout: float = DEFAULT_AGENT_ACTION_TIMEOUT, 39 seed: int | None = None, 40 training: bool = False, 41 extra_info: dict[str, typing.Any] | None = None, 42 ) -> None: 43 if (seed is None): 44 seed = random.randint(0, 2**64) 45 46 self.seed: int = seed 47 """ The random seed for this game's RNG. """ 48 49 self.board_source: str = board_source 50 """ Where the board from this game is loaded from. """ 51 52 self.agent_infos: dict[int, pacai.core.agentinfo.AgentInfo] = agent_infos 53 """ The required information for creating the agents for this game. """ 54 55 if (len(self.agent_infos) == 0): 56 raise ValueError("No agents provided.") 57 58 self.isolation_level: pacai.core.isolation.level.Level = isolation_level 59 """ The isolation level to use for this game. """ 60 61 self.max_turns: int = max_turns 62 """ 63 The total number of moves (between all agents) allowed for this game. 64 If -1, unlimited moves are allowed. 65 """ 66 67 self.agent_start_timeout: float = agent_start_timeout 68 """ 69 The maximum number of seconds an agent is allowed when starting a game. 70 If <= 0, unlimited time is allowed. 71 """ 72 73 self.agent_end_timeout: float = agent_end_timeout 74 """ 75 The maximum number of seconds an agent is allowed when ending a game. 76 If <= 0, unlimited time is allowed. 77 """ 78 79 self.agent_action_timeout: float = agent_action_timeout 80 """ 81 The maximum number of seconds an agent is allowed when getting an action. 82 If <= 0, unlimited time is allowed. 83 """ 84 85 self.training: bool = training 86 """ Whether this game is meant for training agents. """ 87 88 if (extra_info is None): 89 extra_info = {} 90 91 self.extra_info: dict[str, typing.Any] = extra_info 92 """ Any additional arguments passed to the game. """ 93 94 def to_dict(self) -> dict[str, typing.Any]: 95 return { 96 'seed': self.seed, 97 'board_source': self.board_source, 98 'agent_infos': {id: info.to_dict() for (id, info) in self.agent_infos.items()}, 99 'isolation_level': self.isolation_level.value, 100 'max_turns': self.max_turns, 101 'agent_start_timeout': self.agent_start_timeout, 102 'agent_end_timeout': self.agent_end_timeout, 103 'agent_action_timeout': self.agent_action_timeout, 104 'training': self.training, 105 'extra_info': self.extra_info, 106 } 107 108 @classmethod 109 def from_dict(cls, data: dict[str, typing.Any]) -> typing.Any: 110 return cls( 111 seed = data.get('seed', None), 112 board_source = data['board_source'], 113 agent_infos = {int(id): pacai.core.agentinfo.AgentInfo.from_dict(raw_info) for (id, raw_info) in data['agent_infos'].items()}, 114 isolation_level = pacai.core.isolation.level.Level(data.get('isolation_level', pacai.core.isolation.level.Level.NONE.value)), 115 max_turns = data.get('max_turns', DEFAULT_MAX_TURNS), 116 agent_start_timeout = data.get('agent_start_timeout', DEFAULT_AGENT_START_TIMEOUT), 117 agent_end_timeout = data.get('agent_end_timeout', DEFAULT_AGENT_END_TIMEOUT), 118 agent_action_timeout = data.get('agent_action_timeout', DEFAULT_AGENT_ACTION_TIMEOUT), 119 training = data.get('training', False), 120 extra_info = data.get('extra_info', None)) 121 122class GameResult(edq.util.json.DictConverter): 123 """ The result of running a game. """ 124 125 def __init__(self, 126 game_id: int, 127 game_info: GameInfo, 128 score: float = 0, 129 game_timeout: bool = False, 130 timeout_agent_indexes: list[int] | None = None, 131 crash_agent_indexes: list[int] | None = None, 132 winning_agent_indexes: list[int] | None = None, 133 start_time: edq.util.time.Timestamp | None = None, 134 end_time: edq.util.time.Timestamp | None = None, 135 history: list[pacai.core.agentaction.AgentActionRecord] | None = None, 136 agent_complete_records: dict[int, pacai.core.agentaction.AgentActionRecord] | None = None, 137 **kwargs: typing.Any) -> None: 138 self.game_id: int = game_id 139 """ The ID of the game result. """ 140 141 self.game_info: GameInfo = game_info 142 """ The core information about this game. """ 143 144 if (start_time is None): 145 start_time = edq.util.time.Timestamp.now() 146 147 self.start_time: edq.util.time.Timestamp = start_time 148 """ The time the game started at. """ 149 150 self.end_time: edq.util.time.Timestamp | None = end_time 151 """ The time the game ended at. """ 152 153 if (history is None): 154 history = [] 155 156 self.history: list[pacai.core.agentaction.AgentActionRecord] = history 157 """ The history of actions taken by each agent in this game. """ 158 159 if (agent_complete_records is None): 160 agent_complete_records = {} 161 162 self.agent_complete_records: dict[int, pacai.core.agentaction.AgentActionRecord] = agent_complete_records 163 """ 164 The record recieved from an agent when the game finishes. 165 For agents that learn, this may include information that the agent learned this game. 166 """ 167 168 self.score: float = score 169 """ The score of the game. """ 170 171 self.game_timeout: bool = game_timeout 172 """ Indicates that the game has timed out (reached the maximum number of moves). """ 173 174 if (timeout_agent_indexes is None): 175 timeout_agent_indexes = [] 176 177 self.timeout_agent_indexes: list[int] = timeout_agent_indexes 178 """ The list of agents that timed out in this game. """ 179 180 if (crash_agent_indexes is None): 181 crash_agent_indexes = [] 182 183 self.crash_agent_indexes: list[int] = crash_agent_indexes 184 """ The list of agents that crashed in this game. """ 185 186 if (winning_agent_indexes is None): 187 winning_agent_indexes = [] 188 189 self.winning_agent_indexes: list[int] = winning_agent_indexes 190 """ 191 The agents that are considered the "winner" of this game. 192 Games may interpret this value in different ways. 193 """ 194 195 def to_dict(self) -> dict[str, typing.Any]: 196 return { 197 'game_id': self.game_id, 198 'game_info': self.game_info.to_dict(), 199 'start_time': self.start_time, 200 'end_time': self.end_time, 201 'history': [item.to_dict() for item in self.history], 202 'agent_complete_records': {agent_index: record.to_dict() for (agent_index, record) in self.agent_complete_records.items()}, 203 'score': self.score, 204 'game_timeout': self.game_timeout, 205 'timeout_agent_indexes': self.timeout_agent_indexes, 206 'crash_agent_indexes': self.crash_agent_indexes, 207 'winning_agent_indexes': self.winning_agent_indexes, 208 } 209 210 @classmethod 211 def from_dict(cls, data: dict[str, typing.Any]) -> typing.Any: 212 agent_complete_records = {} 213 for (agent_index, raw_record) in data.get('agent_complete_records', {}).items(): 214 agent_complete_records[agent_index] = pacai.core.agentaction.AgentActionRecord.from_dict(raw_record) 215 216 return cls( 217 data['game_id'], 218 GameInfo.from_dict(data['game_info']), 219 start_time = data.get('start_time', None), 220 end_time = data.get('end_time', None), 221 history = [pacai.core.agentaction.AgentActionRecord.from_dict(item) for item in data.get('history', [])], 222 agent_complete_records = agent_complete_records, 223 score = data.get('score', 0), 224 game_timeout = data.get('game_timeout', False), 225 timeout_agent_indexes = data.get('timeout_agent_indexes', None), 226 crash_agent_indexes = data.get('crash_agent_indexes', None), 227 winning_agent_indexes = data.get('winning_agent_indexes', -1), 228 ) 229 230 def get_duration_secs(self) -> float: 231 """ 232 Get the game's duration in seconds. 233 Will return positive infinity if the game has no end time 234 (it is still going or crashed (in very rare cases)). 235 """ 236 237 if (self.end_time is None): 238 return math.inf 239 240 return self.end_time.sub(self.start_time).to_secs() 241 242class Game(abc.ABC): 243 """ 244 A game that can be run in pacai. 245 Games combine the rules, layouts, and agents to run. 246 """ 247 248 def __init__(self, 249 game_info: GameInfo, 250 board: pacai.core.board.Board, 251 save_path: str | None = None, 252 is_replay: bool = False, 253 ) -> None: 254 self.game_info: GameInfo = game_info 255 """ The core information about this game. """ 256 257 self._board: pacai.core.board.Board = board 258 """ The board this game will be played on. """ 259 260 self._save_path: str | None = save_path 261 """ Where to save the results of this game. """ 262 263 self._is_replay: bool = is_replay 264 """ 265 Indicates that this game is being loaded from a replay. 266 Some behavior, like saving the result, will be modified. 267 """ 268 269 def process_args(self, args: argparse.Namespace) -> None: 270 """ Process any special arguments from the command-line. """ 271 272 @abc.abstractmethod 273 def get_initial_state(self, 274 rng: random.Random, 275 board: pacai.core.board.Board, 276 agent_infos: dict[int, pacai.core.agentinfo.AgentInfo], 277 ) -> pacai.core.gamestate.GameState: 278 """ Create the initial state for this game. """ 279 280 def process_turn(self, 281 state: pacai.core.gamestate.GameState, 282 action_record: pacai.core.agentaction.AgentActionRecord, 283 result: GameResult, 284 rng: random.Random, 285 ) -> pacai.core.gamestate.GameState: 286 """ 287 Process the given agent action and return an updated game state. 288 The returned game state may be a copy or modified version of the passed in game state. 289 """ 290 291 # The agent has timed out. 292 if (action_record.timeout): 293 result.timeout_agent_indexes.append(action_record.agent_index) 294 state.process_agent_timeout(action_record.agent_index) 295 return state 296 297 # The agent has crashed. 298 if (action_record.crashed): 299 result.crash_agent_indexes.append(action_record.agent_index) 300 state.process_agent_crash(action_record.agent_index) 301 return state 302 303 action = action_record.get_action() 304 if (action not in state.get_legal_actions()): 305 raise ValueError(f"Illegal action for agent {action_record.agent_index}: '{action}'.") 306 307 self._call_state_process_turn_full(state, action, rng) 308 309 return state 310 311 def _call_state_process_turn_full(self, 312 state: pacai.core.gamestate.GameState, 313 action: pacai.core.action.Action, 314 rng: random.Random) -> None: 315 """ Call on the game state to process a full turn. """ 316 317 state.process_turn_full(action, rng) 318 319 def check_end(self, state: pacai.core.gamestate.GameState) -> bool: 320 """ 321 Check to see if the game is over. 322 Return True if the game is now over, False otherwise. 323 324 By default, this will just check pacai.core.gamestate.GameState.game_over, 325 but child games can override for more complex functionality. 326 """ 327 328 return state.game_over 329 330 def game_complete(self, state: pacai.core.gamestate.GameState, result: GameResult) -> None: 331 """ 332 Make any last adjustments to the game result after the game is over. 333 """ 334 335 def run(self, ui: pacai.core.ui.UI) -> GameResult: 336 """ 337 The main "game loop" for all games. 338 """ 339 340 logging.debug("Starting a game with seed: %d.", self.game_info.seed) 341 342 # Create a new random number generator just for this game. 343 rng = random.Random(self.game_info.seed) 344 345 # Keep track of what happens during this game. 346 game_id = rng.randint(0, 2**64) 347 result = GameResult(game_id, self.game_info) 348 349 # Initialize the agent isolator. 350 isolator = self.game_info.isolation_level.get_isolator() 351 isolator.init_agents(self.game_info.agent_infos) 352 353 # Keep track of all the user inputs since the last time an agent moved. 354 # Note that we need to keep track for all agents, 355 # since the UI will only tell us the inputs since the last call. 356 agent_user_inputs: dict[int, list[pacai.core.action.Action]] = {} 357 for agent_index in self.game_info.agent_infos: 358 agent_user_inputs[agent_index] = [] 359 360 # Create the initial game state (and force it's seed). 361 state = self.get_initial_state(rng, self._board, self.game_info.agent_infos) 362 state.seed = game_id 363 state.game_start() 364 365 board_highlights: list[pacai.core.board.Highlight] = [] 366 367 # Notify agents about the start of the game. 368 records = isolator.game_start(rng, state, self.game_info.agent_start_timeout) 369 for record in records.values(): 370 if (record.timeout): 371 result.timeout_agent_indexes.append(record.agent_index) 372 state.process_agent_timeout(record.agent_index) 373 elif (record.crashed): 374 result.crash_agent_indexes.append(record.agent_index) 375 state.process_agent_crash(record.agent_index) 376 else: 377 board_highlights += record.get_board_highlights() 378 379 state.agents_game_start(records) 380 381 # Start the UI. 382 ui.game_start(state, board_highlights = board_highlights) 383 384 while (not self.check_end(state)): 385 logging.trace("Turn %d, agent %d.", state.turn_count, state.agent_index) # type: ignore[attr-defined] # pylint: disable=no-member 386 387 # Receive any user inputs from the UI. 388 self._receive_user_inputs(agent_user_inputs, ui) 389 390 # Get the next action from the agent. 391 action_record = isolator.get_action(state, agent_user_inputs[state.agent_index], self.game_info.agent_action_timeout) 392 393 # Check if we need to clear any user inputs. 394 if (action_record.get_clear_inputs()): 395 agent_user_inputs[state.agent_index] = [] 396 397 # Execute the next action and update the state. 398 state = self.process_turn(state, action_record, result, rng) 399 400 # Update the UI. 401 ui.update(state, board_highlights = action_record.get_board_highlights()) 402 403 # Update the game result and move history. 404 result.history.append(action_record) 405 406 # Check for game ending conditions. 407 if (self.check_end(state)): 408 break 409 410 # Check if this game has ran for the maximum number of turns. 411 if ((self.game_info.max_turns > 0) and (state.turn_count >= self.game_info.max_turns)): 412 state.process_game_timeout() 413 result.game_timeout = True 414 break 415 416 # Mark the end time of the game. 417 result.end_time = edq.util.time.Timestamp.now() 418 419 # Notify the state about the end of the game. 420 winners = state.game_complete() 421 result.winning_agent_indexes += winners 422 423 result.score = state.score 424 425 # Notify agents about the end of this game. 426 result.agent_complete_records = isolator.game_complete(state, self.game_info.agent_end_timeout) 427 428 # All the game to make final updates to the result. 429 self.game_complete(state, result) 430 431 # Update the UI. 432 ui.game_complete(state) 433 434 # Cleanup 435 isolator.close() 436 ui.close() 437 438 if ((not self._is_replay) and (self._save_path is not None)): 439 logging.info("Saving results to '%s'.", self._save_path) 440 edq.util.json.dump_path(result, self._save_path) 441 442 return result 443 444 def _receive_user_inputs(self, 445 agent_user_inputs: dict[int, list[pacai.core.action.Action]], 446 ui: pacai.core.ui.UI, 447 ) -> None: 448 """ Add the current user inputs to the running list for each agent. """ 449 450 new_user_inputs = ui.get_user_inputs() 451 452 for user_inputs in agent_user_inputs.values(): 453 user_inputs += new_user_inputs 454 455 @classmethod 456 def override_args_with_replay(cls, 457 args: argparse.Namespace, base_agent_infos: dict[int, pacai.core.agentinfo.AgentInfo]) -> None: 458 """ 459 Override the args with the settings from the replay in the args. 460 Children may extend this for additional functionality. 461 """ 462 463 logging.info("Loading replay from '%s'.", args.replay_path) 464 replay_info = typing.cast(GameResult, edq.util.json.load_object_path(args.replay_path, GameResult)) 465 466 # Overrides from the replay info. 467 args.board = replay_info.game_info.board_source 468 args.seed = replay_info.game_info.seed 469 470 # Special settings for replays. 471 args.num_games = 1 472 args.num_training = 0 473 args.max_turns = len(replay_info.history) 474 475 # Script the moves for each agent based on the replay's history. 476 scripted_actions: dict[int, list[pacai.core.action.Action]] = {} 477 for item in replay_info.history: 478 if (item.agent_index not in scripted_actions): 479 scripted_actions[item.agent_index] = [] 480 481 scripted_actions[item.agent_index].append(item.get_action()) 482 483 base_agent_infos.clear() 484 485 for (agent_index, actions) in scripted_actions.items(): 486 base_agent_infos[agent_index] = pacai.core.agentinfo.AgentInfo( 487 name = pacai.util.alias.AGENT_SCRIPTED.short, 488 move_delay = replay_info.game_info.agent_infos[agent_index].move_delay, 489 actions = actions, 490 ) 491 492def set_cli_args(parser: argparse.ArgumentParser, default_board: str | None = None) -> argparse.ArgumentParser: 493 """ 494 Set common CLI arguments. 495 This is a sibling to init_from_args(), as the arguments set here can be interpreted there. 496 """ 497 498 parser.add_argument('--board', dest = 'board', 499 action = 'store', type = str, default = default_board, 500 help = ('Play on this board (default: %(default)s).' 501 + ' This may be the full path to a board, or just a filename.' 502 + ' If just a filename, than the `pacai/resources/boards` directory will be checked (using a ".board" extension.')) 503 504 parser.add_argument('--num-games', dest = 'num_games', 505 action = 'store', type = int, default = 1, 506 help = 'The number of games to play (default: %(default)s).') 507 508 parser.add_argument('--num-training', dest = 'num_training', 509 action = 'store', type = int, default = 0, 510 help = 'The number of games to play in training mode before playing `--num-games` real games (default: %(default)s).') 511 512 parser.add_argument('--seed', dest = 'seed', 513 action = 'store', type = int, default = None, 514 help = 'The random seed for the game (will be randomly generated if not set.') 515 516 parser.add_argument('--max-turns', dest = 'max_turns', 517 action = 'store', type = int, default = DEFAULT_MAX_TURNS, 518 help = 'The maximum number of turns/moves (total for all agents) allowed in this game (-1 for unlimited) (default: %(default)s).') 519 520 parser.add_argument('--agent-start-timeout', dest = 'agent_start_timeout', 521 action = 'store', type = float, default = DEFAULT_AGENT_START_TIMEOUT, 522 help = ('The maximum number of seconds each agent is allowed when starting a game (<= 0 for unlimited time) (default: %(default)s).' 523 + ' Note that the "none" isolation level cannot enforce timeouts.')) 524 525 parser.add_argument('--agent-end-timeout', dest = 'agent_end_timeout', 526 action = 'store', type = float, default = DEFAULT_AGENT_END_TIMEOUT, 527 help = ('The maximum number of seconds each agent is allowed when ending a game (<= 0 for unlimited time) (default: %(default)s).' 528 + ' Note that the "none" isolation level cannot enforce timeouts.')) 529 530 parser.add_argument('--agent-action-timeout', dest = 'agent_action_timeout', 531 action = 'store', type = float, default = DEFAULT_AGENT_ACTION_TIMEOUT, 532 help = ('The maximum number of seconds each agent is allowed when getting an action (<= 0 for unlimited time) (default: %(default)s).' 533 + ' Note that the "none" isolation level cannot enforce timeouts.')) 534 535 parser.add_argument('--isolation', dest = 'isolation_level', metavar = 'LEVEL', 536 action = 'store', type = str, default = pacai.core.isolation.level.Level.NONE.value, 537 choices = pacai.core.isolation.level.LEVELS, 538 help = ('Set the agent isolation level for this game (default: %(default)s).' 539 + ' Choose one of:' 540 + ' `none` -- Do not make any attempt to isolate the agent code from the game (fastest and least secure),' 541 + ' `process` -- Run the agent code in a separate process' 542 + ' (offers some protection, but still vulnerable to disk or execution exploits),' 543 + ' `tcp` -- Open TCP listeners to communicate with agents (most secure, requires additional work to set up agents).')) 544 545 parser.add_argument('--agent-arg', dest = 'raw_agent_args', metavar = 'ARG', 546 action = 'append', type = str, default = [], 547 help = ('Specify arguments directly to agents (may be used multiple times).' 548 + ' The value for this argument must be formatted as "agent_index::key=value",' 549 + ' for example to set `foo = 9` for agent 3 and `bar = a` for agent 2, we can use:' 550 + ' `--agent-arg 3::foo=9 --agent-arg 1::bar=a`.')) 551 552 parser.add_argument('--remove-agent', dest = 'remove_agent_indexes', metavar = 'AGENT_INDEX', 553 action = 'append', type = int, default = [], 554 help = 'Remove this agent from the board before starting (may be used multiple times).') 555 556 parser.add_argument('--save-path', dest = 'save_path', 557 action = 'store', type = str, default = None, 558 help = ('If specified, write the result of this game to the specified location.' 559 + ' This file can be replayed with `--replay-path`.')) 560 561 parser.add_argument('--replay-path', dest = 'replay_path', 562 action = 'store', type = str, default = None, 563 help = 'If specified, replay the game whose result was saved at the specified path with `--save-path`.') 564 565 return parser 566 567def init_from_args( 568 args: argparse.Namespace, 569 game_class: typing.Type[Game], 570 base_agent_infos: dict[int, pacai.core.agentinfo.AgentInfo] | None = None, 571 remove_agent_indexes: list[int] | None = None, 572 board_options: dict[str, typing.Any] | None = None, 573 ) -> argparse.Namespace: 574 """ 575 Take in args from a parser that was passed to set_cli_args(), 576 and initialize the proper components. 577 This will create a number of games (and related resources) 578 based on `--num-games` + `--num-training`. 579 Each of these resources will be placed in their respective list at 580 `args._boards`, `args._agent_infos`, or `args._games`. 581 """ 582 583 if (base_agent_infos is None): 584 base_agent_infos = {} 585 586 if (remove_agent_indexes is None): 587 remove_agent_indexes = [] 588 589 # If this is a replay, 590 # then all the core arguments are loaded differently (directly from the file). 591 # Use the replay file to override all the current options. 592 if (args.replay_path is not None): 593 game_class.override_args_with_replay(args, base_agent_infos) 594 remove_agent_indexes = [] 595 596 if (args.board is None): 597 raise ValueError("No board was specified.") 598 599 total_games = args.num_games + args.num_training 600 601 if (total_games <= 0): 602 raise ValueError(f"At least one game must be played (--num-games + --num-training), {total_games} was specified.") 603 604 # Establish an RNG to generate seeds for each game using the given seed. 605 seed = args.seed 606 if (seed is None): 607 seed = random.randint(0, 2**64) 608 609 logging.debug("Using source seed for games: %d.", seed) 610 rng = random.Random(seed) 611 612 if (board_options is None): 613 board_options = {} 614 615 # Either take the board as given, or load it from a path. 616 if (isinstance(args.board, pacai.core.board.Board)): 617 board = args.board 618 else: 619 board = pacai.core.board.load_path(args.board, **board_options) 620 621 # Remove specified agents from the board. 622 remove_agent_indexes += args.remove_agent_indexes 623 for remove_agent_index in remove_agent_indexes: 624 board.remove_agent(remove_agent_index) 625 626 agent_infos = _parse_agent_infos(board.agent_indexes(), args.raw_agent_args, base_agent_infos, remove_agent_indexes) 627 628 base_save_path = args.save_path 629 630 all_boards = [] 631 all_agent_infos = [] 632 all_games = [] 633 634 for i in range(total_games): 635 game_seed = rng.randint(0, 2**64) 636 637 all_boards.append(board.copy()) 638 all_agent_infos.append(copy.deepcopy(agent_infos)) 639 640 game_info = GameInfo( 641 board.source, 642 all_agent_infos[-1], 643 isolation_level = pacai.core.isolation.level.Level(args.isolation_level), 644 max_turns = args.max_turns, 645 agent_start_timeout = args.agent_start_timeout, 646 agent_end_timeout = args.agent_end_timeout, 647 agent_action_timeout = args.agent_action_timeout, 648 training = (i < args.num_training), 649 seed = game_seed 650 ) 651 652 # Suffix the save path if there is more than one game. 653 save_path = base_save_path 654 if ((save_path is not None) and (total_games > 1)): 655 parts = os.path.splitext(save_path) 656 save_path = f"{parts[0]}_{i:03d}{parts[1]}" 657 658 game_args = { 659 'game_info': game_info, 660 'board': all_boards[-1], 661 'save_path': save_path, 662 } 663 664 game = game_class(**game_args) 665 game.process_args(args) 666 667 all_games.append(game) 668 669 setattr(args, '_boards', all_boards) 670 setattr(args, '_agent_infos', all_agent_infos) 671 setattr(args, '_games', all_games) 672 673 return args 674 675def _parse_agent_infos( 676 agent_indexes: list[int], 677 raw_args: list[str], 678 base_agent_infos: dict[int, pacai.core.agentinfo.AgentInfo], 679 remove_agent_indexes: list[int]) -> dict[int, pacai.core.agentinfo.AgentInfo]: 680 # Initialize with random agents. 681 agent_info = {agent_index: pacai.core.agentinfo.AgentInfo(name = DEFAULT_AGENT) for agent_index in sorted(agent_indexes)} 682 683 # Take any args from the base args. 684 for (agent_index, base_agent_info) in base_agent_infos.items(): 685 if (agent_index in agent_info): 686 agent_info[agent_index].update(base_agent_info) 687 688 # Update with CLI args. 689 for raw_arg in raw_args: 690 raw_arg = raw_arg.strip() 691 if (len(raw_arg) == 0): 692 continue 693 694 parts = raw_arg.split('::', 1) 695 if (len(parts) != 2): 696 raise ValueError(f"Improperly formatted CLI agent argument: '{raw_arg}'.") 697 698 agent_index = int(parts[0]) 699 if (agent_index not in agent_info): 700 raise ValueError(f"CLI agent argument has an unknown agent index: {agent_index}.") 701 702 raw_pair = parts[1] 703 704 parts = raw_pair.split('=', 1) 705 if (len(parts) != 2): 706 raise ValueError(f"Improperly formatted CLI agent argument key/value pair: '{raw_pair}'.") 707 708 key = parts[0].strip() 709 value = parts[1].strip() 710 711 agent_info[agent_index].set_from_string(key, value) 712 713 # Remove specified agents. 714 for remove_agent_index in remove_agent_indexes: 715 if (remove_agent_index in agent_info): 716 del agent_info[remove_agent_index] 717 718 return agent_info
27class GameInfo(edq.util.json.DictConverter): 28 """ 29 A simple container that holds common information about a game. 30 """ 31 32 def __init__(self, 33 board_source: str, 34 agent_infos: dict[int, pacai.core.agentinfo.AgentInfo], 35 isolation_level: pacai.core.isolation.level.Level = pacai.core.isolation.level.Level.NONE, 36 max_turns: int = DEFAULT_MAX_TURNS, 37 agent_start_timeout: float = DEFAULT_AGENT_START_TIMEOUT, 38 agent_end_timeout: float = DEFAULT_AGENT_END_TIMEOUT, 39 agent_action_timeout: float = DEFAULT_AGENT_ACTION_TIMEOUT, 40 seed: int | None = None, 41 training: bool = False, 42 extra_info: dict[str, typing.Any] | None = None, 43 ) -> None: 44 if (seed is None): 45 seed = random.randint(0, 2**64) 46 47 self.seed: int = seed 48 """ The random seed for this game's RNG. """ 49 50 self.board_source: str = board_source 51 """ Where the board from this game is loaded from. """ 52 53 self.agent_infos: dict[int, pacai.core.agentinfo.AgentInfo] = agent_infos 54 """ The required information for creating the agents for this game. """ 55 56 if (len(self.agent_infos) == 0): 57 raise ValueError("No agents provided.") 58 59 self.isolation_level: pacai.core.isolation.level.Level = isolation_level 60 """ The isolation level to use for this game. """ 61 62 self.max_turns: int = max_turns 63 """ 64 The total number of moves (between all agents) allowed for this game. 65 If -1, unlimited moves are allowed. 66 """ 67 68 self.agent_start_timeout: float = agent_start_timeout 69 """ 70 The maximum number of seconds an agent is allowed when starting a game. 71 If <= 0, unlimited time is allowed. 72 """ 73 74 self.agent_end_timeout: float = agent_end_timeout 75 """ 76 The maximum number of seconds an agent is allowed when ending a game. 77 If <= 0, unlimited time is allowed. 78 """ 79 80 self.agent_action_timeout: float = agent_action_timeout 81 """ 82 The maximum number of seconds an agent is allowed when getting an action. 83 If <= 0, unlimited time is allowed. 84 """ 85 86 self.training: bool = training 87 """ Whether this game is meant for training agents. """ 88 89 if (extra_info is None): 90 extra_info = {} 91 92 self.extra_info: dict[str, typing.Any] = extra_info 93 """ Any additional arguments passed to the game. """ 94 95 def to_dict(self) -> dict[str, typing.Any]: 96 return { 97 'seed': self.seed, 98 'board_source': self.board_source, 99 'agent_infos': {id: info.to_dict() for (id, info) in self.agent_infos.items()}, 100 'isolation_level': self.isolation_level.value, 101 'max_turns': self.max_turns, 102 'agent_start_timeout': self.agent_start_timeout, 103 'agent_end_timeout': self.agent_end_timeout, 104 'agent_action_timeout': self.agent_action_timeout, 105 'training': self.training, 106 'extra_info': self.extra_info, 107 } 108 109 @classmethod 110 def from_dict(cls, data: dict[str, typing.Any]) -> typing.Any: 111 return cls( 112 seed = data.get('seed', None), 113 board_source = data['board_source'], 114 agent_infos = {int(id): pacai.core.agentinfo.AgentInfo.from_dict(raw_info) for (id, raw_info) in data['agent_infos'].items()}, 115 isolation_level = pacai.core.isolation.level.Level(data.get('isolation_level', pacai.core.isolation.level.Level.NONE.value)), 116 max_turns = data.get('max_turns', DEFAULT_MAX_TURNS), 117 agent_start_timeout = data.get('agent_start_timeout', DEFAULT_AGENT_START_TIMEOUT), 118 agent_end_timeout = data.get('agent_end_timeout', DEFAULT_AGENT_END_TIMEOUT), 119 agent_action_timeout = data.get('agent_action_timeout', DEFAULT_AGENT_ACTION_TIMEOUT), 120 training = data.get('training', False), 121 extra_info = data.get('extra_info', None))
A simple container that holds common information about a game.
32 def __init__(self, 33 board_source: str, 34 agent_infos: dict[int, pacai.core.agentinfo.AgentInfo], 35 isolation_level: pacai.core.isolation.level.Level = pacai.core.isolation.level.Level.NONE, 36 max_turns: int = DEFAULT_MAX_TURNS, 37 agent_start_timeout: float = DEFAULT_AGENT_START_TIMEOUT, 38 agent_end_timeout: float = DEFAULT_AGENT_END_TIMEOUT, 39 agent_action_timeout: float = DEFAULT_AGENT_ACTION_TIMEOUT, 40 seed: int | None = None, 41 training: bool = False, 42 extra_info: dict[str, typing.Any] | None = None, 43 ) -> None: 44 if (seed is None): 45 seed = random.randint(0, 2**64) 46 47 self.seed: int = seed 48 """ The random seed for this game's RNG. """ 49 50 self.board_source: str = board_source 51 """ Where the board from this game is loaded from. """ 52 53 self.agent_infos: dict[int, pacai.core.agentinfo.AgentInfo] = agent_infos 54 """ The required information for creating the agents for this game. """ 55 56 if (len(self.agent_infos) == 0): 57 raise ValueError("No agents provided.") 58 59 self.isolation_level: pacai.core.isolation.level.Level = isolation_level 60 """ The isolation level to use for this game. """ 61 62 self.max_turns: int = max_turns 63 """ 64 The total number of moves (between all agents) allowed for this game. 65 If -1, unlimited moves are allowed. 66 """ 67 68 self.agent_start_timeout: float = agent_start_timeout 69 """ 70 The maximum number of seconds an agent is allowed when starting a game. 71 If <= 0, unlimited time is allowed. 72 """ 73 74 self.agent_end_timeout: float = agent_end_timeout 75 """ 76 The maximum number of seconds an agent is allowed when ending a game. 77 If <= 0, unlimited time is allowed. 78 """ 79 80 self.agent_action_timeout: float = agent_action_timeout 81 """ 82 The maximum number of seconds an agent is allowed when getting an action. 83 If <= 0, unlimited time is allowed. 84 """ 85 86 self.training: bool = training 87 """ Whether this game is meant for training agents. """ 88 89 if (extra_info is None): 90 extra_info = {} 91 92 self.extra_info: dict[str, typing.Any] = extra_info 93 """ Any additional arguments passed to the game. """
The required information for creating the agents for this game.
The total number of moves (between all agents) allowed for this game. If -1, unlimited moves are allowed.
The maximum number of seconds an agent is allowed when starting a game. If <= 0, unlimited time is allowed.
The maximum number of seconds an agent is allowed when ending a game. If <= 0, unlimited time is allowed.
The maximum number of seconds an agent is allowed when getting an action. If <= 0, unlimited time is allowed.
95 def to_dict(self) -> dict[str, typing.Any]: 96 return { 97 'seed': self.seed, 98 'board_source': self.board_source, 99 'agent_infos': {id: info.to_dict() for (id, info) in self.agent_infos.items()}, 100 'isolation_level': self.isolation_level.value, 101 'max_turns': self.max_turns, 102 'agent_start_timeout': self.agent_start_timeout, 103 'agent_end_timeout': self.agent_end_timeout, 104 'agent_action_timeout': self.agent_action_timeout, 105 'training': self.training, 106 'extra_info': self.extra_info, 107 }
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.
109 @classmethod 110 def from_dict(cls, data: dict[str, typing.Any]) -> typing.Any: 111 return cls( 112 seed = data.get('seed', None), 113 board_source = data['board_source'], 114 agent_infos = {int(id): pacai.core.agentinfo.AgentInfo.from_dict(raw_info) for (id, raw_info) in data['agent_infos'].items()}, 115 isolation_level = pacai.core.isolation.level.Level(data.get('isolation_level', pacai.core.isolation.level.Level.NONE.value)), 116 max_turns = data.get('max_turns', DEFAULT_MAX_TURNS), 117 agent_start_timeout = data.get('agent_start_timeout', DEFAULT_AGENT_START_TIMEOUT), 118 agent_end_timeout = data.get('agent_end_timeout', DEFAULT_AGENT_END_TIMEOUT), 119 agent_action_timeout = data.get('agent_action_timeout', DEFAULT_AGENT_ACTION_TIMEOUT), 120 training = data.get('training', False), 121 extra_info = data.get('extra_info', None))
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.
123class GameResult(edq.util.json.DictConverter): 124 """ The result of running a game. """ 125 126 def __init__(self, 127 game_id: int, 128 game_info: GameInfo, 129 score: float = 0, 130 game_timeout: bool = False, 131 timeout_agent_indexes: list[int] | None = None, 132 crash_agent_indexes: list[int] | None = None, 133 winning_agent_indexes: list[int] | None = None, 134 start_time: edq.util.time.Timestamp | None = None, 135 end_time: edq.util.time.Timestamp | None = None, 136 history: list[pacai.core.agentaction.AgentActionRecord] | None = None, 137 agent_complete_records: dict[int, pacai.core.agentaction.AgentActionRecord] | None = None, 138 **kwargs: typing.Any) -> None: 139 self.game_id: int = game_id 140 """ The ID of the game result. """ 141 142 self.game_info: GameInfo = game_info 143 """ The core information about this game. """ 144 145 if (start_time is None): 146 start_time = edq.util.time.Timestamp.now() 147 148 self.start_time: edq.util.time.Timestamp = start_time 149 """ The time the game started at. """ 150 151 self.end_time: edq.util.time.Timestamp | None = end_time 152 """ The time the game ended at. """ 153 154 if (history is None): 155 history = [] 156 157 self.history: list[pacai.core.agentaction.AgentActionRecord] = history 158 """ The history of actions taken by each agent in this game. """ 159 160 if (agent_complete_records is None): 161 agent_complete_records = {} 162 163 self.agent_complete_records: dict[int, pacai.core.agentaction.AgentActionRecord] = agent_complete_records 164 """ 165 The record recieved from an agent when the game finishes. 166 For agents that learn, this may include information that the agent learned this game. 167 """ 168 169 self.score: float = score 170 """ The score of the game. """ 171 172 self.game_timeout: bool = game_timeout 173 """ Indicates that the game has timed out (reached the maximum number of moves). """ 174 175 if (timeout_agent_indexes is None): 176 timeout_agent_indexes = [] 177 178 self.timeout_agent_indexes: list[int] = timeout_agent_indexes 179 """ The list of agents that timed out in this game. """ 180 181 if (crash_agent_indexes is None): 182 crash_agent_indexes = [] 183 184 self.crash_agent_indexes: list[int] = crash_agent_indexes 185 """ The list of agents that crashed in this game. """ 186 187 if (winning_agent_indexes is None): 188 winning_agent_indexes = [] 189 190 self.winning_agent_indexes: list[int] = winning_agent_indexes 191 """ 192 The agents that are considered the "winner" of this game. 193 Games may interpret this value in different ways. 194 """ 195 196 def to_dict(self) -> dict[str, typing.Any]: 197 return { 198 'game_id': self.game_id, 199 'game_info': self.game_info.to_dict(), 200 'start_time': self.start_time, 201 'end_time': self.end_time, 202 'history': [item.to_dict() for item in self.history], 203 'agent_complete_records': {agent_index: record.to_dict() for (agent_index, record) in self.agent_complete_records.items()}, 204 'score': self.score, 205 'game_timeout': self.game_timeout, 206 'timeout_agent_indexes': self.timeout_agent_indexes, 207 'crash_agent_indexes': self.crash_agent_indexes, 208 'winning_agent_indexes': self.winning_agent_indexes, 209 } 210 211 @classmethod 212 def from_dict(cls, data: dict[str, typing.Any]) -> typing.Any: 213 agent_complete_records = {} 214 for (agent_index, raw_record) in data.get('agent_complete_records', {}).items(): 215 agent_complete_records[agent_index] = pacai.core.agentaction.AgentActionRecord.from_dict(raw_record) 216 217 return cls( 218 data['game_id'], 219 GameInfo.from_dict(data['game_info']), 220 start_time = data.get('start_time', None), 221 end_time = data.get('end_time', None), 222 history = [pacai.core.agentaction.AgentActionRecord.from_dict(item) for item in data.get('history', [])], 223 agent_complete_records = agent_complete_records, 224 score = data.get('score', 0), 225 game_timeout = data.get('game_timeout', False), 226 timeout_agent_indexes = data.get('timeout_agent_indexes', None), 227 crash_agent_indexes = data.get('crash_agent_indexes', None), 228 winning_agent_indexes = data.get('winning_agent_indexes', -1), 229 ) 230 231 def get_duration_secs(self) -> float: 232 """ 233 Get the game's duration in seconds. 234 Will return positive infinity if the game has no end time 235 (it is still going or crashed (in very rare cases)). 236 """ 237 238 if (self.end_time is None): 239 return math.inf 240 241 return self.end_time.sub(self.start_time).to_secs()
The result of running a game.
126 def __init__(self, 127 game_id: int, 128 game_info: GameInfo, 129 score: float = 0, 130 game_timeout: bool = False, 131 timeout_agent_indexes: list[int] | None = None, 132 crash_agent_indexes: list[int] | None = None, 133 winning_agent_indexes: list[int] | None = None, 134 start_time: edq.util.time.Timestamp | None = None, 135 end_time: edq.util.time.Timestamp | None = None, 136 history: list[pacai.core.agentaction.AgentActionRecord] | None = None, 137 agent_complete_records: dict[int, pacai.core.agentaction.AgentActionRecord] | None = None, 138 **kwargs: typing.Any) -> None: 139 self.game_id: int = game_id 140 """ The ID of the game result. """ 141 142 self.game_info: GameInfo = game_info 143 """ The core information about this game. """ 144 145 if (start_time is None): 146 start_time = edq.util.time.Timestamp.now() 147 148 self.start_time: edq.util.time.Timestamp = start_time 149 """ The time the game started at. """ 150 151 self.end_time: edq.util.time.Timestamp | None = end_time 152 """ The time the game ended at. """ 153 154 if (history is None): 155 history = [] 156 157 self.history: list[pacai.core.agentaction.AgentActionRecord] = history 158 """ The history of actions taken by each agent in this game. """ 159 160 if (agent_complete_records is None): 161 agent_complete_records = {} 162 163 self.agent_complete_records: dict[int, pacai.core.agentaction.AgentActionRecord] = agent_complete_records 164 """ 165 The record recieved from an agent when the game finishes. 166 For agents that learn, this may include information that the agent learned this game. 167 """ 168 169 self.score: float = score 170 """ The score of the game. """ 171 172 self.game_timeout: bool = game_timeout 173 """ Indicates that the game has timed out (reached the maximum number of moves). """ 174 175 if (timeout_agent_indexes is None): 176 timeout_agent_indexes = [] 177 178 self.timeout_agent_indexes: list[int] = timeout_agent_indexes 179 """ The list of agents that timed out in this game. """ 180 181 if (crash_agent_indexes is None): 182 crash_agent_indexes = [] 183 184 self.crash_agent_indexes: list[int] = crash_agent_indexes 185 """ The list of agents that crashed in this game. """ 186 187 if (winning_agent_indexes is None): 188 winning_agent_indexes = [] 189 190 self.winning_agent_indexes: list[int] = winning_agent_indexes 191 """ 192 The agents that are considered the "winner" of this game. 193 Games may interpret this value in different ways. 194 """
The history of actions taken by each agent in this game.
The record recieved from an agent when the game finishes. For agents that learn, this may include information that the agent learned this game.
The agents that are considered the "winner" of this game. Games may interpret this value in different ways.
196 def to_dict(self) -> dict[str, typing.Any]: 197 return { 198 'game_id': self.game_id, 199 'game_info': self.game_info.to_dict(), 200 'start_time': self.start_time, 201 'end_time': self.end_time, 202 'history': [item.to_dict() for item in self.history], 203 'agent_complete_records': {agent_index: record.to_dict() for (agent_index, record) in self.agent_complete_records.items()}, 204 'score': self.score, 205 'game_timeout': self.game_timeout, 206 'timeout_agent_indexes': self.timeout_agent_indexes, 207 'crash_agent_indexes': self.crash_agent_indexes, 208 'winning_agent_indexes': self.winning_agent_indexes, 209 }
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.
211 @classmethod 212 def from_dict(cls, data: dict[str, typing.Any]) -> typing.Any: 213 agent_complete_records = {} 214 for (agent_index, raw_record) in data.get('agent_complete_records', {}).items(): 215 agent_complete_records[agent_index] = pacai.core.agentaction.AgentActionRecord.from_dict(raw_record) 216 217 return cls( 218 data['game_id'], 219 GameInfo.from_dict(data['game_info']), 220 start_time = data.get('start_time', None), 221 end_time = data.get('end_time', None), 222 history = [pacai.core.agentaction.AgentActionRecord.from_dict(item) for item in data.get('history', [])], 223 agent_complete_records = agent_complete_records, 224 score = data.get('score', 0), 225 game_timeout = data.get('game_timeout', False), 226 timeout_agent_indexes = data.get('timeout_agent_indexes', None), 227 crash_agent_indexes = data.get('crash_agent_indexes', None), 228 winning_agent_indexes = data.get('winning_agent_indexes', -1), 229 )
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.
231 def get_duration_secs(self) -> float: 232 """ 233 Get the game's duration in seconds. 234 Will return positive infinity if the game has no end time 235 (it is still going or crashed (in very rare cases)). 236 """ 237 238 if (self.end_time is None): 239 return math.inf 240 241 return self.end_time.sub(self.start_time).to_secs()
Get the game's duration in seconds. Will return positive infinity if the game has no end time (it is still going or crashed (in very rare cases)).
243class Game(abc.ABC): 244 """ 245 A game that can be run in pacai. 246 Games combine the rules, layouts, and agents to run. 247 """ 248 249 def __init__(self, 250 game_info: GameInfo, 251 board: pacai.core.board.Board, 252 save_path: str | None = None, 253 is_replay: bool = False, 254 ) -> None: 255 self.game_info: GameInfo = game_info 256 """ The core information about this game. """ 257 258 self._board: pacai.core.board.Board = board 259 """ The board this game will be played on. """ 260 261 self._save_path: str | None = save_path 262 """ Where to save the results of this game. """ 263 264 self._is_replay: bool = is_replay 265 """ 266 Indicates that this game is being loaded from a replay. 267 Some behavior, like saving the result, will be modified. 268 """ 269 270 def process_args(self, args: argparse.Namespace) -> None: 271 """ Process any special arguments from the command-line. """ 272 273 @abc.abstractmethod 274 def get_initial_state(self, 275 rng: random.Random, 276 board: pacai.core.board.Board, 277 agent_infos: dict[int, pacai.core.agentinfo.AgentInfo], 278 ) -> pacai.core.gamestate.GameState: 279 """ Create the initial state for this game. """ 280 281 def process_turn(self, 282 state: pacai.core.gamestate.GameState, 283 action_record: pacai.core.agentaction.AgentActionRecord, 284 result: GameResult, 285 rng: random.Random, 286 ) -> pacai.core.gamestate.GameState: 287 """ 288 Process the given agent action and return an updated game state. 289 The returned game state may be a copy or modified version of the passed in game state. 290 """ 291 292 # The agent has timed out. 293 if (action_record.timeout): 294 result.timeout_agent_indexes.append(action_record.agent_index) 295 state.process_agent_timeout(action_record.agent_index) 296 return state 297 298 # The agent has crashed. 299 if (action_record.crashed): 300 result.crash_agent_indexes.append(action_record.agent_index) 301 state.process_agent_crash(action_record.agent_index) 302 return state 303 304 action = action_record.get_action() 305 if (action not in state.get_legal_actions()): 306 raise ValueError(f"Illegal action for agent {action_record.agent_index}: '{action}'.") 307 308 self._call_state_process_turn_full(state, action, rng) 309 310 return state 311 312 def _call_state_process_turn_full(self, 313 state: pacai.core.gamestate.GameState, 314 action: pacai.core.action.Action, 315 rng: random.Random) -> None: 316 """ Call on the game state to process a full turn. """ 317 318 state.process_turn_full(action, rng) 319 320 def check_end(self, state: pacai.core.gamestate.GameState) -> bool: 321 """ 322 Check to see if the game is over. 323 Return True if the game is now over, False otherwise. 324 325 By default, this will just check pacai.core.gamestate.GameState.game_over, 326 but child games can override for more complex functionality. 327 """ 328 329 return state.game_over 330 331 def game_complete(self, state: pacai.core.gamestate.GameState, result: GameResult) -> None: 332 """ 333 Make any last adjustments to the game result after the game is over. 334 """ 335 336 def run(self, ui: pacai.core.ui.UI) -> GameResult: 337 """ 338 The main "game loop" for all games. 339 """ 340 341 logging.debug("Starting a game with seed: %d.", self.game_info.seed) 342 343 # Create a new random number generator just for this game. 344 rng = random.Random(self.game_info.seed) 345 346 # Keep track of what happens during this game. 347 game_id = rng.randint(0, 2**64) 348 result = GameResult(game_id, self.game_info) 349 350 # Initialize the agent isolator. 351 isolator = self.game_info.isolation_level.get_isolator() 352 isolator.init_agents(self.game_info.agent_infos) 353 354 # Keep track of all the user inputs since the last time an agent moved. 355 # Note that we need to keep track for all agents, 356 # since the UI will only tell us the inputs since the last call. 357 agent_user_inputs: dict[int, list[pacai.core.action.Action]] = {} 358 for agent_index in self.game_info.agent_infos: 359 agent_user_inputs[agent_index] = [] 360 361 # Create the initial game state (and force it's seed). 362 state = self.get_initial_state(rng, self._board, self.game_info.agent_infos) 363 state.seed = game_id 364 state.game_start() 365 366 board_highlights: list[pacai.core.board.Highlight] = [] 367 368 # Notify agents about the start of the game. 369 records = isolator.game_start(rng, state, self.game_info.agent_start_timeout) 370 for record in records.values(): 371 if (record.timeout): 372 result.timeout_agent_indexes.append(record.agent_index) 373 state.process_agent_timeout(record.agent_index) 374 elif (record.crashed): 375 result.crash_agent_indexes.append(record.agent_index) 376 state.process_agent_crash(record.agent_index) 377 else: 378 board_highlights += record.get_board_highlights() 379 380 state.agents_game_start(records) 381 382 # Start the UI. 383 ui.game_start(state, board_highlights = board_highlights) 384 385 while (not self.check_end(state)): 386 logging.trace("Turn %d, agent %d.", state.turn_count, state.agent_index) # type: ignore[attr-defined] # pylint: disable=no-member 387 388 # Receive any user inputs from the UI. 389 self._receive_user_inputs(agent_user_inputs, ui) 390 391 # Get the next action from the agent. 392 action_record = isolator.get_action(state, agent_user_inputs[state.agent_index], self.game_info.agent_action_timeout) 393 394 # Check if we need to clear any user inputs. 395 if (action_record.get_clear_inputs()): 396 agent_user_inputs[state.agent_index] = [] 397 398 # Execute the next action and update the state. 399 state = self.process_turn(state, action_record, result, rng) 400 401 # Update the UI. 402 ui.update(state, board_highlights = action_record.get_board_highlights()) 403 404 # Update the game result and move history. 405 result.history.append(action_record) 406 407 # Check for game ending conditions. 408 if (self.check_end(state)): 409 break 410 411 # Check if this game has ran for the maximum number of turns. 412 if ((self.game_info.max_turns > 0) and (state.turn_count >= self.game_info.max_turns)): 413 state.process_game_timeout() 414 result.game_timeout = True 415 break 416 417 # Mark the end time of the game. 418 result.end_time = edq.util.time.Timestamp.now() 419 420 # Notify the state about the end of the game. 421 winners = state.game_complete() 422 result.winning_agent_indexes += winners 423 424 result.score = state.score 425 426 # Notify agents about the end of this game. 427 result.agent_complete_records = isolator.game_complete(state, self.game_info.agent_end_timeout) 428 429 # All the game to make final updates to the result. 430 self.game_complete(state, result) 431 432 # Update the UI. 433 ui.game_complete(state) 434 435 # Cleanup 436 isolator.close() 437 ui.close() 438 439 if ((not self._is_replay) and (self._save_path is not None)): 440 logging.info("Saving results to '%s'.", self._save_path) 441 edq.util.json.dump_path(result, self._save_path) 442 443 return result 444 445 def _receive_user_inputs(self, 446 agent_user_inputs: dict[int, list[pacai.core.action.Action]], 447 ui: pacai.core.ui.UI, 448 ) -> None: 449 """ Add the current user inputs to the running list for each agent. """ 450 451 new_user_inputs = ui.get_user_inputs() 452 453 for user_inputs in agent_user_inputs.values(): 454 user_inputs += new_user_inputs 455 456 @classmethod 457 def override_args_with_replay(cls, 458 args: argparse.Namespace, base_agent_infos: dict[int, pacai.core.agentinfo.AgentInfo]) -> None: 459 """ 460 Override the args with the settings from the replay in the args. 461 Children may extend this for additional functionality. 462 """ 463 464 logging.info("Loading replay from '%s'.", args.replay_path) 465 replay_info = typing.cast(GameResult, edq.util.json.load_object_path(args.replay_path, GameResult)) 466 467 # Overrides from the replay info. 468 args.board = replay_info.game_info.board_source 469 args.seed = replay_info.game_info.seed 470 471 # Special settings for replays. 472 args.num_games = 1 473 args.num_training = 0 474 args.max_turns = len(replay_info.history) 475 476 # Script the moves for each agent based on the replay's history. 477 scripted_actions: dict[int, list[pacai.core.action.Action]] = {} 478 for item in replay_info.history: 479 if (item.agent_index not in scripted_actions): 480 scripted_actions[item.agent_index] = [] 481 482 scripted_actions[item.agent_index].append(item.get_action()) 483 484 base_agent_infos.clear() 485 486 for (agent_index, actions) in scripted_actions.items(): 487 base_agent_infos[agent_index] = pacai.core.agentinfo.AgentInfo( 488 name = pacai.util.alias.AGENT_SCRIPTED.short, 489 move_delay = replay_info.game_info.agent_infos[agent_index].move_delay, 490 actions = actions, 491 )
A game that can be run in pacai. Games combine the rules, layouts, and agents to run.
270 def process_args(self, args: argparse.Namespace) -> None: 271 """ Process any special arguments from the command-line. """
Process any special arguments from the command-line.
273 @abc.abstractmethod 274 def get_initial_state(self, 275 rng: random.Random, 276 board: pacai.core.board.Board, 277 agent_infos: dict[int, pacai.core.agentinfo.AgentInfo], 278 ) -> pacai.core.gamestate.GameState: 279 """ Create the initial state for this game. """
Create the initial state for this game.
281 def process_turn(self, 282 state: pacai.core.gamestate.GameState, 283 action_record: pacai.core.agentaction.AgentActionRecord, 284 result: GameResult, 285 rng: random.Random, 286 ) -> pacai.core.gamestate.GameState: 287 """ 288 Process the given agent action and return an updated game state. 289 The returned game state may be a copy or modified version of the passed in game state. 290 """ 291 292 # The agent has timed out. 293 if (action_record.timeout): 294 result.timeout_agent_indexes.append(action_record.agent_index) 295 state.process_agent_timeout(action_record.agent_index) 296 return state 297 298 # The agent has crashed. 299 if (action_record.crashed): 300 result.crash_agent_indexes.append(action_record.agent_index) 301 state.process_agent_crash(action_record.agent_index) 302 return state 303 304 action = action_record.get_action() 305 if (action not in state.get_legal_actions()): 306 raise ValueError(f"Illegal action for agent {action_record.agent_index}: '{action}'.") 307 308 self._call_state_process_turn_full(state, action, rng) 309 310 return state
Process the given agent action and return an updated game state. The returned game state may be a copy or modified version of the passed in game state.
320 def check_end(self, state: pacai.core.gamestate.GameState) -> bool: 321 """ 322 Check to see if the game is over. 323 Return True if the game is now over, False otherwise. 324 325 By default, this will just check pacai.core.gamestate.GameState.game_over, 326 but child games can override for more complex functionality. 327 """ 328 329 return state.game_over
Check to see if the game is over. Return True if the game is now over, False otherwise.
By default, this will just check pacai.core.gamestate.GameState.game_over, but child games can override for more complex functionality.
331 def game_complete(self, state: pacai.core.gamestate.GameState, result: GameResult) -> None: 332 """ 333 Make any last adjustments to the game result after the game is over. 334 """
Make any last adjustments to the game result after the game is over.
336 def run(self, ui: pacai.core.ui.UI) -> GameResult: 337 """ 338 The main "game loop" for all games. 339 """ 340 341 logging.debug("Starting a game with seed: %d.", self.game_info.seed) 342 343 # Create a new random number generator just for this game. 344 rng = random.Random(self.game_info.seed) 345 346 # Keep track of what happens during this game. 347 game_id = rng.randint(0, 2**64) 348 result = GameResult(game_id, self.game_info) 349 350 # Initialize the agent isolator. 351 isolator = self.game_info.isolation_level.get_isolator() 352 isolator.init_agents(self.game_info.agent_infos) 353 354 # Keep track of all the user inputs since the last time an agent moved. 355 # Note that we need to keep track for all agents, 356 # since the UI will only tell us the inputs since the last call. 357 agent_user_inputs: dict[int, list[pacai.core.action.Action]] = {} 358 for agent_index in self.game_info.agent_infos: 359 agent_user_inputs[agent_index] = [] 360 361 # Create the initial game state (and force it's seed). 362 state = self.get_initial_state(rng, self._board, self.game_info.agent_infos) 363 state.seed = game_id 364 state.game_start() 365 366 board_highlights: list[pacai.core.board.Highlight] = [] 367 368 # Notify agents about the start of the game. 369 records = isolator.game_start(rng, state, self.game_info.agent_start_timeout) 370 for record in records.values(): 371 if (record.timeout): 372 result.timeout_agent_indexes.append(record.agent_index) 373 state.process_agent_timeout(record.agent_index) 374 elif (record.crashed): 375 result.crash_agent_indexes.append(record.agent_index) 376 state.process_agent_crash(record.agent_index) 377 else: 378 board_highlights += record.get_board_highlights() 379 380 state.agents_game_start(records) 381 382 # Start the UI. 383 ui.game_start(state, board_highlights = board_highlights) 384 385 while (not self.check_end(state)): 386 logging.trace("Turn %d, agent %d.", state.turn_count, state.agent_index) # type: ignore[attr-defined] # pylint: disable=no-member 387 388 # Receive any user inputs from the UI. 389 self._receive_user_inputs(agent_user_inputs, ui) 390 391 # Get the next action from the agent. 392 action_record = isolator.get_action(state, agent_user_inputs[state.agent_index], self.game_info.agent_action_timeout) 393 394 # Check if we need to clear any user inputs. 395 if (action_record.get_clear_inputs()): 396 agent_user_inputs[state.agent_index] = [] 397 398 # Execute the next action and update the state. 399 state = self.process_turn(state, action_record, result, rng) 400 401 # Update the UI. 402 ui.update(state, board_highlights = action_record.get_board_highlights()) 403 404 # Update the game result and move history. 405 result.history.append(action_record) 406 407 # Check for game ending conditions. 408 if (self.check_end(state)): 409 break 410 411 # Check if this game has ran for the maximum number of turns. 412 if ((self.game_info.max_turns > 0) and (state.turn_count >= self.game_info.max_turns)): 413 state.process_game_timeout() 414 result.game_timeout = True 415 break 416 417 # Mark the end time of the game. 418 result.end_time = edq.util.time.Timestamp.now() 419 420 # Notify the state about the end of the game. 421 winners = state.game_complete() 422 result.winning_agent_indexes += winners 423 424 result.score = state.score 425 426 # Notify agents about the end of this game. 427 result.agent_complete_records = isolator.game_complete(state, self.game_info.agent_end_timeout) 428 429 # All the game to make final updates to the result. 430 self.game_complete(state, result) 431 432 # Update the UI. 433 ui.game_complete(state) 434 435 # Cleanup 436 isolator.close() 437 ui.close() 438 439 if ((not self._is_replay) and (self._save_path is not None)): 440 logging.info("Saving results to '%s'.", self._save_path) 441 edq.util.json.dump_path(result, self._save_path) 442 443 return result
The main "game loop" for all games.
456 @classmethod 457 def override_args_with_replay(cls, 458 args: argparse.Namespace, base_agent_infos: dict[int, pacai.core.agentinfo.AgentInfo]) -> None: 459 """ 460 Override the args with the settings from the replay in the args. 461 Children may extend this for additional functionality. 462 """ 463 464 logging.info("Loading replay from '%s'.", args.replay_path) 465 replay_info = typing.cast(GameResult, edq.util.json.load_object_path(args.replay_path, GameResult)) 466 467 # Overrides from the replay info. 468 args.board = replay_info.game_info.board_source 469 args.seed = replay_info.game_info.seed 470 471 # Special settings for replays. 472 args.num_games = 1 473 args.num_training = 0 474 args.max_turns = len(replay_info.history) 475 476 # Script the moves for each agent based on the replay's history. 477 scripted_actions: dict[int, list[pacai.core.action.Action]] = {} 478 for item in replay_info.history: 479 if (item.agent_index not in scripted_actions): 480 scripted_actions[item.agent_index] = [] 481 482 scripted_actions[item.agent_index].append(item.get_action()) 483 484 base_agent_infos.clear() 485 486 for (agent_index, actions) in scripted_actions.items(): 487 base_agent_infos[agent_index] = pacai.core.agentinfo.AgentInfo( 488 name = pacai.util.alias.AGENT_SCRIPTED.short, 489 move_delay = replay_info.game_info.agent_infos[agent_index].move_delay, 490 actions = actions, 491 )
Override the args with the settings from the replay in the args. Children may extend this for additional functionality.
493def set_cli_args(parser: argparse.ArgumentParser, default_board: str | None = None) -> argparse.ArgumentParser: 494 """ 495 Set common CLI arguments. 496 This is a sibling to init_from_args(), as the arguments set here can be interpreted there. 497 """ 498 499 parser.add_argument('--board', dest = 'board', 500 action = 'store', type = str, default = default_board, 501 help = ('Play on this board (default: %(default)s).' 502 + ' This may be the full path to a board, or just a filename.' 503 + ' If just a filename, than the `pacai/resources/boards` directory will be checked (using a ".board" extension.')) 504 505 parser.add_argument('--num-games', dest = 'num_games', 506 action = 'store', type = int, default = 1, 507 help = 'The number of games to play (default: %(default)s).') 508 509 parser.add_argument('--num-training', dest = 'num_training', 510 action = 'store', type = int, default = 0, 511 help = 'The number of games to play in training mode before playing `--num-games` real games (default: %(default)s).') 512 513 parser.add_argument('--seed', dest = 'seed', 514 action = 'store', type = int, default = None, 515 help = 'The random seed for the game (will be randomly generated if not set.') 516 517 parser.add_argument('--max-turns', dest = 'max_turns', 518 action = 'store', type = int, default = DEFAULT_MAX_TURNS, 519 help = 'The maximum number of turns/moves (total for all agents) allowed in this game (-1 for unlimited) (default: %(default)s).') 520 521 parser.add_argument('--agent-start-timeout', dest = 'agent_start_timeout', 522 action = 'store', type = float, default = DEFAULT_AGENT_START_TIMEOUT, 523 help = ('The maximum number of seconds each agent is allowed when starting a game (<= 0 for unlimited time) (default: %(default)s).' 524 + ' Note that the "none" isolation level cannot enforce timeouts.')) 525 526 parser.add_argument('--agent-end-timeout', dest = 'agent_end_timeout', 527 action = 'store', type = float, default = DEFAULT_AGENT_END_TIMEOUT, 528 help = ('The maximum number of seconds each agent is allowed when ending a game (<= 0 for unlimited time) (default: %(default)s).' 529 + ' Note that the "none" isolation level cannot enforce timeouts.')) 530 531 parser.add_argument('--agent-action-timeout', dest = 'agent_action_timeout', 532 action = 'store', type = float, default = DEFAULT_AGENT_ACTION_TIMEOUT, 533 help = ('The maximum number of seconds each agent is allowed when getting an action (<= 0 for unlimited time) (default: %(default)s).' 534 + ' Note that the "none" isolation level cannot enforce timeouts.')) 535 536 parser.add_argument('--isolation', dest = 'isolation_level', metavar = 'LEVEL', 537 action = 'store', type = str, default = pacai.core.isolation.level.Level.NONE.value, 538 choices = pacai.core.isolation.level.LEVELS, 539 help = ('Set the agent isolation level for this game (default: %(default)s).' 540 + ' Choose one of:' 541 + ' `none` -- Do not make any attempt to isolate the agent code from the game (fastest and least secure),' 542 + ' `process` -- Run the agent code in a separate process' 543 + ' (offers some protection, but still vulnerable to disk or execution exploits),' 544 + ' `tcp` -- Open TCP listeners to communicate with agents (most secure, requires additional work to set up agents).')) 545 546 parser.add_argument('--agent-arg', dest = 'raw_agent_args', metavar = 'ARG', 547 action = 'append', type = str, default = [], 548 help = ('Specify arguments directly to agents (may be used multiple times).' 549 + ' The value for this argument must be formatted as "agent_index::key=value",' 550 + ' for example to set `foo = 9` for agent 3 and `bar = a` for agent 2, we can use:' 551 + ' `--agent-arg 3::foo=9 --agent-arg 1::bar=a`.')) 552 553 parser.add_argument('--remove-agent', dest = 'remove_agent_indexes', metavar = 'AGENT_INDEX', 554 action = 'append', type = int, default = [], 555 help = 'Remove this agent from the board before starting (may be used multiple times).') 556 557 parser.add_argument('--save-path', dest = 'save_path', 558 action = 'store', type = str, default = None, 559 help = ('If specified, write the result of this game to the specified location.' 560 + ' This file can be replayed with `--replay-path`.')) 561 562 parser.add_argument('--replay-path', dest = 'replay_path', 563 action = 'store', type = str, default = None, 564 help = 'If specified, replay the game whose result was saved at the specified path with `--save-path`.') 565 566 return parser
Set common CLI arguments. This is a sibling to init_from_args(), as the arguments set here can be interpreted there.
568def init_from_args( 569 args: argparse.Namespace, 570 game_class: typing.Type[Game], 571 base_agent_infos: dict[int, pacai.core.agentinfo.AgentInfo] | None = None, 572 remove_agent_indexes: list[int] | None = None, 573 board_options: dict[str, typing.Any] | None = None, 574 ) -> argparse.Namespace: 575 """ 576 Take in args from a parser that was passed to set_cli_args(), 577 and initialize the proper components. 578 This will create a number of games (and related resources) 579 based on `--num-games` + `--num-training`. 580 Each of these resources will be placed in their respective list at 581 `args._boards`, `args._agent_infos`, or `args._games`. 582 """ 583 584 if (base_agent_infos is None): 585 base_agent_infos = {} 586 587 if (remove_agent_indexes is None): 588 remove_agent_indexes = [] 589 590 # If this is a replay, 591 # then all the core arguments are loaded differently (directly from the file). 592 # Use the replay file to override all the current options. 593 if (args.replay_path is not None): 594 game_class.override_args_with_replay(args, base_agent_infos) 595 remove_agent_indexes = [] 596 597 if (args.board is None): 598 raise ValueError("No board was specified.") 599 600 total_games = args.num_games + args.num_training 601 602 if (total_games <= 0): 603 raise ValueError(f"At least one game must be played (--num-games + --num-training), {total_games} was specified.") 604 605 # Establish an RNG to generate seeds for each game using the given seed. 606 seed = args.seed 607 if (seed is None): 608 seed = random.randint(0, 2**64) 609 610 logging.debug("Using source seed for games: %d.", seed) 611 rng = random.Random(seed) 612 613 if (board_options is None): 614 board_options = {} 615 616 # Either take the board as given, or load it from a path. 617 if (isinstance(args.board, pacai.core.board.Board)): 618 board = args.board 619 else: 620 board = pacai.core.board.load_path(args.board, **board_options) 621 622 # Remove specified agents from the board. 623 remove_agent_indexes += args.remove_agent_indexes 624 for remove_agent_index in remove_agent_indexes: 625 board.remove_agent(remove_agent_index) 626 627 agent_infos = _parse_agent_infos(board.agent_indexes(), args.raw_agent_args, base_agent_infos, remove_agent_indexes) 628 629 base_save_path = args.save_path 630 631 all_boards = [] 632 all_agent_infos = [] 633 all_games = [] 634 635 for i in range(total_games): 636 game_seed = rng.randint(0, 2**64) 637 638 all_boards.append(board.copy()) 639 all_agent_infos.append(copy.deepcopy(agent_infos)) 640 641 game_info = GameInfo( 642 board.source, 643 all_agent_infos[-1], 644 isolation_level = pacai.core.isolation.level.Level(args.isolation_level), 645 max_turns = args.max_turns, 646 agent_start_timeout = args.agent_start_timeout, 647 agent_end_timeout = args.agent_end_timeout, 648 agent_action_timeout = args.agent_action_timeout, 649 training = (i < args.num_training), 650 seed = game_seed 651 ) 652 653 # Suffix the save path if there is more than one game. 654 save_path = base_save_path 655 if ((save_path is not None) and (total_games > 1)): 656 parts = os.path.splitext(save_path) 657 save_path = f"{parts[0]}_{i:03d}{parts[1]}" 658 659 game_args = { 660 'game_info': game_info, 661 'board': all_boards[-1], 662 'save_path': save_path, 663 } 664 665 game = game_class(**game_args) 666 game.process_args(args) 667 668 all_games.append(game) 669 670 setattr(args, '_boards', all_boards) 671 setattr(args, '_agent_infos', all_agent_infos) 672 setattr(args, '_games', all_games) 673 674 return args
Take in args from a parser that was passed to set_cli_args(),
and initialize the proper components.
This will create a number of games (and related resources)
based on --num-games + --num-training.
Each of these resources will be placed in their respective list at
args._boards, args._agent_infos, or args._games.