pacai.core.gamestate
1import copy 2import random 3import typing 4 5import PIL.Image 6import edq.util.json 7 8import pacai.core.action 9import pacai.core.agentaction 10import pacai.core.agentinfo 11import pacai.core.board 12import pacai.core.font 13import pacai.core.spritesheet 14import pacai.core.ticket 15import pacai.util.math 16 17class GameState(edq.util.json.DictConverter): 18 """ 19 The base for all game states in pacai. 20 A game state should contain all the information about the current state of the game. 21 22 Game states should only be interacted with via their methods and not their member variables 23 (since this class has been optimized for performance). 24 """ 25 26 def __init__(self, 27 board: pacai.core.board.Board | None = None, 28 seed: int = -1, 29 agent_index: int = -1, 30 game_over: bool = False, 31 agent_actions: dict[int, list[pacai.core.action.Action]] | None = None, 32 score: float = 0, 33 turn_count: int = 0, 34 move_delays: dict[int, int] | None = None, 35 tickets: dict[int, pacai.core.ticket.Ticket] | None = None, 36 agent_infos: dict[int, pacai.core.agentinfo.AgentInfo] | None = None, 37 **kwargs: typing.Any) -> None: 38 if (board is None): 39 raise ValueError("Cannot construct a game state without a board.") 40 41 self.board: pacai.core.board.Board = board 42 """ The current board. """ 43 44 self.seed: int = seed 45 """ A utility seed that components using the game state may use to seed their own RNGs. """ 46 47 self.agent_index: int = agent_index 48 """ 49 The index of the agent with the current move. 50 -1 indicates that the agent to move has not been selected yet. 51 """ 52 53 self.last_agent_index: int = -1 54 """ 55 The index of the agent who just moved. 56 -1 indicates that no move has been made yet. 57 """ 58 59 self.game_over: bool = game_over 60 """ Indicates that this state represents a complete game. """ 61 62 if (agent_actions is None): 63 agent_actions = {} 64 65 self.agent_actions: dict[int, list[pacai.core.action.Action]] = agent_actions 66 """ Keep track of the actions that each agent makes. """ 67 68 self.score: float = score 69 """ The current score of the game. """ 70 71 self.turn_count: int = turn_count 72 """ The number of turns (agent actions) that the game has had. """ 73 74 if (move_delays is None): 75 move_delays = {} 76 77 self.move_delays: dict[int, int] = move_delays 78 """ 79 The current move delay for each agent. 80 Every agent should always have a move delay. 81 """ 82 83 if (tickets is None): 84 tickets = {} 85 86 self.tickets: dict[int, pacai.core.ticket.Ticket] = tickets 87 """ 88 The current ticket for each agent. 89 Every agent should always have a ticket once the game starts (even if it is not taking a move). 90 """ 91 92 # Initialize data from agent arguments if not enough info is provided. 93 if ((len(self.move_delays) == 0) and (agent_infos is not None)): 94 for (info_agent_index, agent_info) in agent_infos.items(): 95 self.move_delays[info_agent_index] = agent_info.move_delay 96 97 def copy(self) -> 'GameState': 98 """ 99 Get a deep copy of this state. 100 101 Child classes are responsible for making any deep copies they need to. 102 """ 103 104 new_state = copy.copy(self) 105 106 new_state.board = self.board.copy() 107 new_state.agent_actions = {agent_index: actions.copy() for (agent_index, actions) in self.agent_actions.items()} 108 new_state.move_delays = self.move_delays.copy() 109 new_state.tickets = self.tickets.copy() 110 111 return new_state 112 113 def game_start(self) -> None: 114 """ 115 Indicate that the game is starting. 116 This will initialize some state like tickets. 117 """ 118 119 # Issue initial tickets. 120 for agent_index in self.move_delays.keys(): 121 self.tickets[agent_index] = pacai.core.ticket.Ticket(agent_index + self.compute_move_delay(agent_index), 0, 0) 122 123 # Choose the first agent to move. 124 self.last_agent_index = self.agent_index 125 self.agent_index = self.get_next_agent_index() 126 127 def agents_game_start(self, agent_responses: dict[int, pacai.core.agentaction.AgentActionRecord]) -> None: 128 """ Indicate that agents have been started. """ 129 130 def game_complete(self) -> list[int]: 131 """ 132 Indicate that the game has ended. 133 The state should take any final actions and return the indexes of the winning agents (if any). 134 """ 135 136 return [] 137 138 def get_num_agents(self) -> int: 139 """ Get the number of agents this state is tracking. """ 140 141 return len(self.tickets) 142 143 def get_agent_indexes(self) -> list[int]: 144 """ Get the index of all agents tracked by this game state. """ 145 146 return list(sorted(self.tickets.keys())) 147 148 def get_agent_positions(self) -> dict[int, pacai.core.board.Position | None]: 149 """ 150 Get the positions of all agents. 151 If an agent is tracked by the game state but not on the board, it will have a None value. 152 """ 153 154 positions: dict[int, pacai.core.board.Position | None] = {} 155 for agent_index in self.tickets: 156 positions[agent_index] = self.get_agent_position(agent_index) 157 158 return positions 159 160 def get_agent_position(self, agent_index: int | None = None) -> pacai.core.board.Position | None: 161 """ Get the position of the specified agent (or current agent if no agent is specified). """ 162 163 if (agent_index is None): 164 agent_index = self.agent_index 165 166 if (agent_index < 0): 167 raise ValueError("No agent is active, cannot get position.") 168 169 return self.board.get_agent_position(agent_index) 170 171 def get_agent_actions(self, agent_index: int | None = None) -> list[pacai.core.action.Action]: 172 """ Get the previous actions of the specified agent (or current agent if no agent is specified). """ 173 174 if (agent_index is None): 175 agent_index = self.agent_index 176 177 if (agent_index < 0): 178 return [] 179 180 if (agent_index not in self.agent_actions): 181 self.agent_actions[agent_index] = [] 182 183 return self.agent_actions[agent_index] 184 185 def get_last_agent_action(self, agent_index: int | None = None) -> pacai.core.action.Action | None: 186 """ Get the last action of the specified agent (or current agent if no agent is specified). """ 187 188 actions = self.get_agent_actions(agent_index) 189 if (len(actions) == 0): 190 return None 191 192 return actions[-1] 193 194 def get_reverse_action(self, action: pacai.core.action.Action | None) -> pacai.core.action.Action | None: 195 """ 196 Get the reverse of an action, or None if the action has no reverse. 197 By default, "reverse" is just defined in terms of cardinal directions. 198 However, this method exists so that child games can override this definition of "reverse" if necessary. 199 """ 200 201 if (action is None): 202 return None 203 204 return pacai.core.action.get_reverse_direction(action) 205 206 def generate_successor(self, 207 action: pacai.core.action.Action, 208 rng: random.Random | None = None, 209 **kwargs: typing.Any) -> 'GameState': 210 """ 211 Create a new deep copy of this state that represents the current agent taking the given action. 212 To just apply an action to the current state, use process_turn(). 213 """ 214 215 successor = self.copy() 216 successor.process_turn_full(action, rng, **kwargs) 217 218 return successor 219 220 def process_agent_timeout(self, agent_index: int) -> None: 221 """ 222 Notify the state that the given agent has timed out. 223 The state should make any updates and set the end of game information. 224 """ 225 226 self.game_over = True 227 228 def process_agent_crash(self, agent_index: int) -> None: 229 """ 230 Notify the state that the given agent has crashed. 231 The state should make any updates and set the end of game information. 232 """ 233 234 self.game_over = True 235 236 def process_game_timeout(self) -> None: 237 """ 238 Notify the state that the game has reached the maximum number of turns without ending. 239 The state should make any updates and set the end of game information. 240 """ 241 242 self.game_over = True 243 244 def process_turn(self, 245 action: pacai.core.action.Action, 246 rng: random.Random | None = None, 247 **kwargs: typing.Any) -> None: 248 """ 249 Process the current agent's turn with the given action. 250 This may modify the current state. 251 To get a copy of a potential successor state, use generate_successor(). 252 """ 253 254 def process_turn_full(self, 255 action: pacai.core.action.Action, 256 rng: random.Random | None = None, 257 **kwargs: typing.Any) -> None: 258 """ 259 Process the current agent's turn with the given action. 260 This will modify the current state. 261 First procecss_turn() will be called, 262 then any bookkeeping will be performed. 263 Child classes should prefer overriding the simpler process_turn(). 264 265 To get a copy of a potential successor state, use generate_successor(). 266 """ 267 268 # If the game is over, don't do anyhting. 269 # This case can come up when planning agent actions and generating successors. 270 if (self.game_over): 271 return 272 273 self.process_turn(action, rng, **kwargs) 274 275 # Track this last action. 276 if (self.agent_index not in self.agent_actions): 277 self.agent_actions[self.agent_index] = [] 278 279 self.agent_actions[self.agent_index].append(action) 280 281 # Issue this agent a new ticket. 282 self.tickets[self.agent_index] = self.tickets[self.agent_index].next(self.compute_move_delay(self.agent_index)) 283 284 # If the game is not over, pick an agent for the next turn. 285 self.last_agent_index = self.agent_index 286 self.agent_index = -1 287 if (not self.game_over): 288 self.agent_index = self.get_next_agent_index() 289 290 # Increment the move count. 291 self.turn_count += 1 292 293 def compute_move_delay(self, agent_index: int) -> int: 294 """ 295 The the current move delay for the agent. 296 By default, this just looks up the value in self.move_delays. 297 However, this method allows children to implement move complex functionality 298 (e.g., power-ups that affect an agent's speed). 299 """ 300 301 return self.move_delays[agent_index] 302 303 def get_next_agent_index(self) -> int: 304 """ 305 Get the agent that moves next. 306 Do this by looking at the agents' tickets and choosing the one with the lowest ticket. 307 """ 308 309 next_index = -1 310 for (agent_index, ticket) in self.tickets.items(): 311 if ((next_index == -1) or (ticket.is_before(self.tickets[next_index]))): 312 next_index = agent_index 313 314 return next_index 315 316 def sprite_lookup(self, 317 sprite_sheet: pacai.core.spritesheet.SpriteSheet, 318 position: pacai.core.board.Position, 319 marker: pacai.core.board.Marker | None = None, 320 action: pacai.core.action.Action | None = None, 321 adjacency: pacai.core.board.AdjacencyString | None = None, 322 animation_key: str | None = None, 323 ) -> PIL.Image.Image: 324 """ 325 Lookup the proper sprite for a situation. 326 By default this just calls into the sprite sheet, 327 but children may override for more expressive functionality. 328 """ 329 330 return sprite_sheet.get_sprite(marker = marker, action = action, adjacency = adjacency, animation_key = animation_key) 331 332 def skip_draw(self, 333 marker: pacai.core.board.Marker, 334 position: pacai.core.board.Position, 335 static: bool = False, 336 ) -> bool: 337 """ Return true if this marker/position combination should not be drawn on the board. """ 338 339 return False 340 341 def get_static_positions(self) -> list[pacai.core.board.Position]: 342 """ Get a list of positions to draw on the board statically. """ 343 344 return [] 345 346 def get_static_text(self) -> list[pacai.core.font.BoardText]: 347 """ Get any static text to display on board positions. """ 348 349 return [] 350 351 def get_nonstatic_text(self) -> list[pacai.core.font.BoardText]: 352 """ Get any non-static text to display on board positions. """ 353 354 return [] 355 356 def get_footer_text(self) -> pacai.core.font.Text | None: 357 """ 358 Get any text to draw in the UI's footer. 359 By default, this will return the score. 360 """ 361 362 score_text = f" Score: {pacai.util.math.display_number(self.score, places = 2)}" 363 if (self.game_over): 364 score_text += " - Final" 365 366 return pacai.core.font.Text(score_text, horizontal_align = pacai.core.font.TextHorizontalAlign.LEFT) 367 368 def to_dict(self) -> dict[str, typing.Any]: 369 data = vars(self).copy() 370 371 data['board'] = self.board.to_dict() 372 data['tickets'] = {agent_index: ticket.to_dict() for (agent_index, ticket) in sorted(self.tickets.items())} 373 data['agent_actions'] = {agent_index: [str(action) for action in actions] for (agent_index, actions) in self.agent_actions.items()} 374 375 return data 376 377 @classmethod 378 def from_dict(cls, data: dict[str, typing.Any]) -> typing.Any: 379 data = data.copy() 380 381 data['board'] = pacai.core.board.Board.from_dict(data['board']) 382 data['tickets'] = {int(agent_index): pacai.core.ticket.Ticket.from_dict(ticket) for (agent_index, ticket) in data['tickets'].items()} 383 data['agent_actions'] = {int(agent_index): [pacai.core.action.Action(raw_action, safe = True) for raw_action in raw_actions] 384 for (agent_index, raw_actions) in data['agent_actions'].items()} 385 386 return cls(**data) 387 388 def get_legal_actions(self, position: pacai.core.board.Position | None = None) -> list[pacai.core.action.Action]: 389 """ 390 Get the moves that the current agent is allowed to make. 391 Stopping is generally always considered a legal action (unless a game re-defines this behavior). 392 393 If a position is provided, it will override the current agent's position. 394 """ 395 396 # Stopping is generally safe. 397 actions = [pacai.core.action.STOP] 398 399 if (position is None): 400 if (self.agent_index == -1): 401 raise ValueError("Cannot get legal actions when no agent is active.") 402 403 position = self.get_agent_position() 404 405 # If the agent is not on the board, it can only stop. 406 if (position is None): 407 return actions 408 409 # Get moves to adjacent positions. 410 neighbor_moves = self.board.get_neighbors(position) 411 for (action, _) in neighbor_moves: 412 actions.append(action) 413 414 return actions 415 416@typing.runtime_checkable 417class AgentStateEvaluationFunction(typing.Protocol): 418 """ 419 A function that can be used to score a game state. 420 """ 421 422 def __call__(self, 423 state: GameState, 424 agent: typing.Any | None = None, 425 action: pacai.core.action.Action | None = None, 426 **kwargs: typing.Any) -> float: 427 """ 428 Compute a score for a state that the provided agent can use to decide actions. 429 The current state is the only required argument, the others are optional. 430 Passing the agent asking for this evaluation is a simple way to pass persistent state 431 (like pre-computed distances) from the agent to this function. 432 """ 433 434def base_eval( 435 state: GameState, 436 agent: typing.Any | None = None, 437 action: pacai.core.action.Action | None = None, 438 **kwargs: typing.Any) -> float: 439 """ The most basic evaluation function, which just uses the state's current score. """ 440 441 return float(state.score)
18class GameState(edq.util.json.DictConverter): 19 """ 20 The base for all game states in pacai. 21 A game state should contain all the information about the current state of the game. 22 23 Game states should only be interacted with via their methods and not their member variables 24 (since this class has been optimized for performance). 25 """ 26 27 def __init__(self, 28 board: pacai.core.board.Board | None = None, 29 seed: int = -1, 30 agent_index: int = -1, 31 game_over: bool = False, 32 agent_actions: dict[int, list[pacai.core.action.Action]] | None = None, 33 score: float = 0, 34 turn_count: int = 0, 35 move_delays: dict[int, int] | None = None, 36 tickets: dict[int, pacai.core.ticket.Ticket] | None = None, 37 agent_infos: dict[int, pacai.core.agentinfo.AgentInfo] | None = None, 38 **kwargs: typing.Any) -> None: 39 if (board is None): 40 raise ValueError("Cannot construct a game state without a board.") 41 42 self.board: pacai.core.board.Board = board 43 """ The current board. """ 44 45 self.seed: int = seed 46 """ A utility seed that components using the game state may use to seed their own RNGs. """ 47 48 self.agent_index: int = agent_index 49 """ 50 The index of the agent with the current move. 51 -1 indicates that the agent to move has not been selected yet. 52 """ 53 54 self.last_agent_index: int = -1 55 """ 56 The index of the agent who just moved. 57 -1 indicates that no move has been made yet. 58 """ 59 60 self.game_over: bool = game_over 61 """ Indicates that this state represents a complete game. """ 62 63 if (agent_actions is None): 64 agent_actions = {} 65 66 self.agent_actions: dict[int, list[pacai.core.action.Action]] = agent_actions 67 """ Keep track of the actions that each agent makes. """ 68 69 self.score: float = score 70 """ The current score of the game. """ 71 72 self.turn_count: int = turn_count 73 """ The number of turns (agent actions) that the game has had. """ 74 75 if (move_delays is None): 76 move_delays = {} 77 78 self.move_delays: dict[int, int] = move_delays 79 """ 80 The current move delay for each agent. 81 Every agent should always have a move delay. 82 """ 83 84 if (tickets is None): 85 tickets = {} 86 87 self.tickets: dict[int, pacai.core.ticket.Ticket] = tickets 88 """ 89 The current ticket for each agent. 90 Every agent should always have a ticket once the game starts (even if it is not taking a move). 91 """ 92 93 # Initialize data from agent arguments if not enough info is provided. 94 if ((len(self.move_delays) == 0) and (agent_infos is not None)): 95 for (info_agent_index, agent_info) in agent_infos.items(): 96 self.move_delays[info_agent_index] = agent_info.move_delay 97 98 def copy(self) -> 'GameState': 99 """ 100 Get a deep copy of this state. 101 102 Child classes are responsible for making any deep copies they need to. 103 """ 104 105 new_state = copy.copy(self) 106 107 new_state.board = self.board.copy() 108 new_state.agent_actions = {agent_index: actions.copy() for (agent_index, actions) in self.agent_actions.items()} 109 new_state.move_delays = self.move_delays.copy() 110 new_state.tickets = self.tickets.copy() 111 112 return new_state 113 114 def game_start(self) -> None: 115 """ 116 Indicate that the game is starting. 117 This will initialize some state like tickets. 118 """ 119 120 # Issue initial tickets. 121 for agent_index in self.move_delays.keys(): 122 self.tickets[agent_index] = pacai.core.ticket.Ticket(agent_index + self.compute_move_delay(agent_index), 0, 0) 123 124 # Choose the first agent to move. 125 self.last_agent_index = self.agent_index 126 self.agent_index = self.get_next_agent_index() 127 128 def agents_game_start(self, agent_responses: dict[int, pacai.core.agentaction.AgentActionRecord]) -> None: 129 """ Indicate that agents have been started. """ 130 131 def game_complete(self) -> list[int]: 132 """ 133 Indicate that the game has ended. 134 The state should take any final actions and return the indexes of the winning agents (if any). 135 """ 136 137 return [] 138 139 def get_num_agents(self) -> int: 140 """ Get the number of agents this state is tracking. """ 141 142 return len(self.tickets) 143 144 def get_agent_indexes(self) -> list[int]: 145 """ Get the index of all agents tracked by this game state. """ 146 147 return list(sorted(self.tickets.keys())) 148 149 def get_agent_positions(self) -> dict[int, pacai.core.board.Position | None]: 150 """ 151 Get the positions of all agents. 152 If an agent is tracked by the game state but not on the board, it will have a None value. 153 """ 154 155 positions: dict[int, pacai.core.board.Position | None] = {} 156 for agent_index in self.tickets: 157 positions[agent_index] = self.get_agent_position(agent_index) 158 159 return positions 160 161 def get_agent_position(self, agent_index: int | None = None) -> pacai.core.board.Position | None: 162 """ Get the position of the specified agent (or current agent if no agent is specified). """ 163 164 if (agent_index is None): 165 agent_index = self.agent_index 166 167 if (agent_index < 0): 168 raise ValueError("No agent is active, cannot get position.") 169 170 return self.board.get_agent_position(agent_index) 171 172 def get_agent_actions(self, agent_index: int | None = None) -> list[pacai.core.action.Action]: 173 """ Get the previous actions of the specified agent (or current agent if no agent is specified). """ 174 175 if (agent_index is None): 176 agent_index = self.agent_index 177 178 if (agent_index < 0): 179 return [] 180 181 if (agent_index not in self.agent_actions): 182 self.agent_actions[agent_index] = [] 183 184 return self.agent_actions[agent_index] 185 186 def get_last_agent_action(self, agent_index: int | None = None) -> pacai.core.action.Action | None: 187 """ Get the last action of the specified agent (or current agent if no agent is specified). """ 188 189 actions = self.get_agent_actions(agent_index) 190 if (len(actions) == 0): 191 return None 192 193 return actions[-1] 194 195 def get_reverse_action(self, action: pacai.core.action.Action | None) -> pacai.core.action.Action | None: 196 """ 197 Get the reverse of an action, or None if the action has no reverse. 198 By default, "reverse" is just defined in terms of cardinal directions. 199 However, this method exists so that child games can override this definition of "reverse" if necessary. 200 """ 201 202 if (action is None): 203 return None 204 205 return pacai.core.action.get_reverse_direction(action) 206 207 def generate_successor(self, 208 action: pacai.core.action.Action, 209 rng: random.Random | None = None, 210 **kwargs: typing.Any) -> 'GameState': 211 """ 212 Create a new deep copy of this state that represents the current agent taking the given action. 213 To just apply an action to the current state, use process_turn(). 214 """ 215 216 successor = self.copy() 217 successor.process_turn_full(action, rng, **kwargs) 218 219 return successor 220 221 def process_agent_timeout(self, agent_index: int) -> None: 222 """ 223 Notify the state that the given agent has timed out. 224 The state should make any updates and set the end of game information. 225 """ 226 227 self.game_over = True 228 229 def process_agent_crash(self, agent_index: int) -> None: 230 """ 231 Notify the state that the given agent has crashed. 232 The state should make any updates and set the end of game information. 233 """ 234 235 self.game_over = True 236 237 def process_game_timeout(self) -> None: 238 """ 239 Notify the state that the game has reached the maximum number of turns without ending. 240 The state should make any updates and set the end of game information. 241 """ 242 243 self.game_over = True 244 245 def process_turn(self, 246 action: pacai.core.action.Action, 247 rng: random.Random | None = None, 248 **kwargs: typing.Any) -> None: 249 """ 250 Process the current agent's turn with the given action. 251 This may modify the current state. 252 To get a copy of a potential successor state, use generate_successor(). 253 """ 254 255 def process_turn_full(self, 256 action: pacai.core.action.Action, 257 rng: random.Random | None = None, 258 **kwargs: typing.Any) -> None: 259 """ 260 Process the current agent's turn with the given action. 261 This will modify the current state. 262 First procecss_turn() will be called, 263 then any bookkeeping will be performed. 264 Child classes should prefer overriding the simpler process_turn(). 265 266 To get a copy of a potential successor state, use generate_successor(). 267 """ 268 269 # If the game is over, don't do anyhting. 270 # This case can come up when planning agent actions and generating successors. 271 if (self.game_over): 272 return 273 274 self.process_turn(action, rng, **kwargs) 275 276 # Track this last action. 277 if (self.agent_index not in self.agent_actions): 278 self.agent_actions[self.agent_index] = [] 279 280 self.agent_actions[self.agent_index].append(action) 281 282 # Issue this agent a new ticket. 283 self.tickets[self.agent_index] = self.tickets[self.agent_index].next(self.compute_move_delay(self.agent_index)) 284 285 # If the game is not over, pick an agent for the next turn. 286 self.last_agent_index = self.agent_index 287 self.agent_index = -1 288 if (not self.game_over): 289 self.agent_index = self.get_next_agent_index() 290 291 # Increment the move count. 292 self.turn_count += 1 293 294 def compute_move_delay(self, agent_index: int) -> int: 295 """ 296 The the current move delay for the agent. 297 By default, this just looks up the value in self.move_delays. 298 However, this method allows children to implement move complex functionality 299 (e.g., power-ups that affect an agent's speed). 300 """ 301 302 return self.move_delays[agent_index] 303 304 def get_next_agent_index(self) -> int: 305 """ 306 Get the agent that moves next. 307 Do this by looking at the agents' tickets and choosing the one with the lowest ticket. 308 """ 309 310 next_index = -1 311 for (agent_index, ticket) in self.tickets.items(): 312 if ((next_index == -1) or (ticket.is_before(self.tickets[next_index]))): 313 next_index = agent_index 314 315 return next_index 316 317 def sprite_lookup(self, 318 sprite_sheet: pacai.core.spritesheet.SpriteSheet, 319 position: pacai.core.board.Position, 320 marker: pacai.core.board.Marker | None = None, 321 action: pacai.core.action.Action | None = None, 322 adjacency: pacai.core.board.AdjacencyString | None = None, 323 animation_key: str | None = None, 324 ) -> PIL.Image.Image: 325 """ 326 Lookup the proper sprite for a situation. 327 By default this just calls into the sprite sheet, 328 but children may override for more expressive functionality. 329 """ 330 331 return sprite_sheet.get_sprite(marker = marker, action = action, adjacency = adjacency, animation_key = animation_key) 332 333 def skip_draw(self, 334 marker: pacai.core.board.Marker, 335 position: pacai.core.board.Position, 336 static: bool = False, 337 ) -> bool: 338 """ Return true if this marker/position combination should not be drawn on the board. """ 339 340 return False 341 342 def get_static_positions(self) -> list[pacai.core.board.Position]: 343 """ Get a list of positions to draw on the board statically. """ 344 345 return [] 346 347 def get_static_text(self) -> list[pacai.core.font.BoardText]: 348 """ Get any static text to display on board positions. """ 349 350 return [] 351 352 def get_nonstatic_text(self) -> list[pacai.core.font.BoardText]: 353 """ Get any non-static text to display on board positions. """ 354 355 return [] 356 357 def get_footer_text(self) -> pacai.core.font.Text | None: 358 """ 359 Get any text to draw in the UI's footer. 360 By default, this will return the score. 361 """ 362 363 score_text = f" Score: {pacai.util.math.display_number(self.score, places = 2)}" 364 if (self.game_over): 365 score_text += " - Final" 366 367 return pacai.core.font.Text(score_text, horizontal_align = pacai.core.font.TextHorizontalAlign.LEFT) 368 369 def to_dict(self) -> dict[str, typing.Any]: 370 data = vars(self).copy() 371 372 data['board'] = self.board.to_dict() 373 data['tickets'] = {agent_index: ticket.to_dict() for (agent_index, ticket) in sorted(self.tickets.items())} 374 data['agent_actions'] = {agent_index: [str(action) for action in actions] for (agent_index, actions) in self.agent_actions.items()} 375 376 return data 377 378 @classmethod 379 def from_dict(cls, data: dict[str, typing.Any]) -> typing.Any: 380 data = data.copy() 381 382 data['board'] = pacai.core.board.Board.from_dict(data['board']) 383 data['tickets'] = {int(agent_index): pacai.core.ticket.Ticket.from_dict(ticket) for (agent_index, ticket) in data['tickets'].items()} 384 data['agent_actions'] = {int(agent_index): [pacai.core.action.Action(raw_action, safe = True) for raw_action in raw_actions] 385 for (agent_index, raw_actions) in data['agent_actions'].items()} 386 387 return cls(**data) 388 389 def get_legal_actions(self, position: pacai.core.board.Position | None = None) -> list[pacai.core.action.Action]: 390 """ 391 Get the moves that the current agent is allowed to make. 392 Stopping is generally always considered a legal action (unless a game re-defines this behavior). 393 394 If a position is provided, it will override the current agent's position. 395 """ 396 397 # Stopping is generally safe. 398 actions = [pacai.core.action.STOP] 399 400 if (position is None): 401 if (self.agent_index == -1): 402 raise ValueError("Cannot get legal actions when no agent is active.") 403 404 position = self.get_agent_position() 405 406 # If the agent is not on the board, it can only stop. 407 if (position is None): 408 return actions 409 410 # Get moves to adjacent positions. 411 neighbor_moves = self.board.get_neighbors(position) 412 for (action, _) in neighbor_moves: 413 actions.append(action) 414 415 return actions
The base for all game states in pacai. A game state should contain all the information about the current state of the game.
Game states should only be interacted with via their methods and not their member variables (since this class has been optimized for performance).
27 def __init__(self, 28 board: pacai.core.board.Board | None = None, 29 seed: int = -1, 30 agent_index: int = -1, 31 game_over: bool = False, 32 agent_actions: dict[int, list[pacai.core.action.Action]] | None = None, 33 score: float = 0, 34 turn_count: int = 0, 35 move_delays: dict[int, int] | None = None, 36 tickets: dict[int, pacai.core.ticket.Ticket] | None = None, 37 agent_infos: dict[int, pacai.core.agentinfo.AgentInfo] | None = None, 38 **kwargs: typing.Any) -> None: 39 if (board is None): 40 raise ValueError("Cannot construct a game state without a board.") 41 42 self.board: pacai.core.board.Board = board 43 """ The current board. """ 44 45 self.seed: int = seed 46 """ A utility seed that components using the game state may use to seed their own RNGs. """ 47 48 self.agent_index: int = agent_index 49 """ 50 The index of the agent with the current move. 51 -1 indicates that the agent to move has not been selected yet. 52 """ 53 54 self.last_agent_index: int = -1 55 """ 56 The index of the agent who just moved. 57 -1 indicates that no move has been made yet. 58 """ 59 60 self.game_over: bool = game_over 61 """ Indicates that this state represents a complete game. """ 62 63 if (agent_actions is None): 64 agent_actions = {} 65 66 self.agent_actions: dict[int, list[pacai.core.action.Action]] = agent_actions 67 """ Keep track of the actions that each agent makes. """ 68 69 self.score: float = score 70 """ The current score of the game. """ 71 72 self.turn_count: int = turn_count 73 """ The number of turns (agent actions) that the game has had. """ 74 75 if (move_delays is None): 76 move_delays = {} 77 78 self.move_delays: dict[int, int] = move_delays 79 """ 80 The current move delay for each agent. 81 Every agent should always have a move delay. 82 """ 83 84 if (tickets is None): 85 tickets = {} 86 87 self.tickets: dict[int, pacai.core.ticket.Ticket] = tickets 88 """ 89 The current ticket for each agent. 90 Every agent should always have a ticket once the game starts (even if it is not taking a move). 91 """ 92 93 # Initialize data from agent arguments if not enough info is provided. 94 if ((len(self.move_delays) == 0) and (agent_infos is not None)): 95 for (info_agent_index, agent_info) in agent_infos.items(): 96 self.move_delays[info_agent_index] = agent_info.move_delay
The index of the agent with the current move. -1 indicates that the agent to move has not been selected yet.
The index of the agent who just moved. -1 indicates that no move has been made yet.
Keep track of the actions that each agent makes.
The current move delay for each agent. Every agent should always have a move delay.
The current ticket for each agent. Every agent should always have a ticket once the game starts (even if it is not taking a move).
98 def copy(self) -> 'GameState': 99 """ 100 Get a deep copy of this state. 101 102 Child classes are responsible for making any deep copies they need to. 103 """ 104 105 new_state = copy.copy(self) 106 107 new_state.board = self.board.copy() 108 new_state.agent_actions = {agent_index: actions.copy() for (agent_index, actions) in self.agent_actions.items()} 109 new_state.move_delays = self.move_delays.copy() 110 new_state.tickets = self.tickets.copy() 111 112 return new_state
Get a deep copy of this state.
Child classes are responsible for making any deep copies they need to.
114 def game_start(self) -> None: 115 """ 116 Indicate that the game is starting. 117 This will initialize some state like tickets. 118 """ 119 120 # Issue initial tickets. 121 for agent_index in self.move_delays.keys(): 122 self.tickets[agent_index] = pacai.core.ticket.Ticket(agent_index + self.compute_move_delay(agent_index), 0, 0) 123 124 # Choose the first agent to move. 125 self.last_agent_index = self.agent_index 126 self.agent_index = self.get_next_agent_index()
Indicate that the game is starting. This will initialize some state like tickets.
128 def agents_game_start(self, agent_responses: dict[int, pacai.core.agentaction.AgentActionRecord]) -> None: 129 """ Indicate that agents have been started. """
Indicate that agents have been started.
131 def game_complete(self) -> list[int]: 132 """ 133 Indicate that the game has ended. 134 The state should take any final actions and return the indexes of the winning agents (if any). 135 """ 136 137 return []
Indicate that the game has ended. The state should take any final actions and return the indexes of the winning agents (if any).
139 def get_num_agents(self) -> int: 140 """ Get the number of agents this state is tracking. """ 141 142 return len(self.tickets)
Get the number of agents this state is tracking.
144 def get_agent_indexes(self) -> list[int]: 145 """ Get the index of all agents tracked by this game state. """ 146 147 return list(sorted(self.tickets.keys()))
Get the index of all agents tracked by this game state.
149 def get_agent_positions(self) -> dict[int, pacai.core.board.Position | None]: 150 """ 151 Get the positions of all agents. 152 If an agent is tracked by the game state but not on the board, it will have a None value. 153 """ 154 155 positions: dict[int, pacai.core.board.Position | None] = {} 156 for agent_index in self.tickets: 157 positions[agent_index] = self.get_agent_position(agent_index) 158 159 return positions
Get the positions of all agents. If an agent is tracked by the game state but not on the board, it will have a None value.
161 def get_agent_position(self, agent_index: int | None = None) -> pacai.core.board.Position | None: 162 """ Get the position of the specified agent (or current agent if no agent is specified). """ 163 164 if (agent_index is None): 165 agent_index = self.agent_index 166 167 if (agent_index < 0): 168 raise ValueError("No agent is active, cannot get position.") 169 170 return self.board.get_agent_position(agent_index)
Get the position of the specified agent (or current agent if no agent is specified).
172 def get_agent_actions(self, agent_index: int | None = None) -> list[pacai.core.action.Action]: 173 """ Get the previous actions of the specified agent (or current agent if no agent is specified). """ 174 175 if (agent_index is None): 176 agent_index = self.agent_index 177 178 if (agent_index < 0): 179 return [] 180 181 if (agent_index not in self.agent_actions): 182 self.agent_actions[agent_index] = [] 183 184 return self.agent_actions[agent_index]
Get the previous actions of the specified agent (or current agent if no agent is specified).
186 def get_last_agent_action(self, agent_index: int | None = None) -> pacai.core.action.Action | None: 187 """ Get the last action of the specified agent (or current agent if no agent is specified). """ 188 189 actions = self.get_agent_actions(agent_index) 190 if (len(actions) == 0): 191 return None 192 193 return actions[-1]
Get the last action of the specified agent (or current agent if no agent is specified).
195 def get_reverse_action(self, action: pacai.core.action.Action | None) -> pacai.core.action.Action | None: 196 """ 197 Get the reverse of an action, or None if the action has no reverse. 198 By default, "reverse" is just defined in terms of cardinal directions. 199 However, this method exists so that child games can override this definition of "reverse" if necessary. 200 """ 201 202 if (action is None): 203 return None 204 205 return pacai.core.action.get_reverse_direction(action)
Get the reverse of an action, or None if the action has no reverse. By default, "reverse" is just defined in terms of cardinal directions. However, this method exists so that child games can override this definition of "reverse" if necessary.
207 def generate_successor(self, 208 action: pacai.core.action.Action, 209 rng: random.Random | None = None, 210 **kwargs: typing.Any) -> 'GameState': 211 """ 212 Create a new deep copy of this state that represents the current agent taking the given action. 213 To just apply an action to the current state, use process_turn(). 214 """ 215 216 successor = self.copy() 217 successor.process_turn_full(action, rng, **kwargs) 218 219 return successor
Create a new deep copy of this state that represents the current agent taking the given action. To just apply an action to the current state, use process_turn().
221 def process_agent_timeout(self, agent_index: int) -> None: 222 """ 223 Notify the state that the given agent has timed out. 224 The state should make any updates and set the end of game information. 225 """ 226 227 self.game_over = True
Notify the state that the given agent has timed out. The state should make any updates and set the end of game information.
229 def process_agent_crash(self, agent_index: int) -> None: 230 """ 231 Notify the state that the given agent has crashed. 232 The state should make any updates and set the end of game information. 233 """ 234 235 self.game_over = True
Notify the state that the given agent has crashed. The state should make any updates and set the end of game information.
237 def process_game_timeout(self) -> None: 238 """ 239 Notify the state that the game has reached the maximum number of turns without ending. 240 The state should make any updates and set the end of game information. 241 """ 242 243 self.game_over = True
Notify the state that the game has reached the maximum number of turns without ending. The state should make any updates and set the end of game information.
245 def process_turn(self, 246 action: pacai.core.action.Action, 247 rng: random.Random | None = None, 248 **kwargs: typing.Any) -> None: 249 """ 250 Process the current agent's turn with the given action. 251 This may modify the current state. 252 To get a copy of a potential successor state, use generate_successor(). 253 """
Process the current agent's turn with the given action. This may modify the current state. To get a copy of a potential successor state, use generate_successor().
255 def process_turn_full(self, 256 action: pacai.core.action.Action, 257 rng: random.Random | None = None, 258 **kwargs: typing.Any) -> None: 259 """ 260 Process the current agent's turn with the given action. 261 This will modify the current state. 262 First procecss_turn() will be called, 263 then any bookkeeping will be performed. 264 Child classes should prefer overriding the simpler process_turn(). 265 266 To get a copy of a potential successor state, use generate_successor(). 267 """ 268 269 # If the game is over, don't do anyhting. 270 # This case can come up when planning agent actions and generating successors. 271 if (self.game_over): 272 return 273 274 self.process_turn(action, rng, **kwargs) 275 276 # Track this last action. 277 if (self.agent_index not in self.agent_actions): 278 self.agent_actions[self.agent_index] = [] 279 280 self.agent_actions[self.agent_index].append(action) 281 282 # Issue this agent a new ticket. 283 self.tickets[self.agent_index] = self.tickets[self.agent_index].next(self.compute_move_delay(self.agent_index)) 284 285 # If the game is not over, pick an agent for the next turn. 286 self.last_agent_index = self.agent_index 287 self.agent_index = -1 288 if (not self.game_over): 289 self.agent_index = self.get_next_agent_index() 290 291 # Increment the move count. 292 self.turn_count += 1
Process the current agent's turn with the given action. This will modify the current state. First procecss_turn() will be called, then any bookkeeping will be performed. Child classes should prefer overriding the simpler process_turn().
To get a copy of a potential successor state, use generate_successor().
294 def compute_move_delay(self, agent_index: int) -> int: 295 """ 296 The the current move delay for the agent. 297 By default, this just looks up the value in self.move_delays. 298 However, this method allows children to implement move complex functionality 299 (e.g., power-ups that affect an agent's speed). 300 """ 301 302 return self.move_delays[agent_index]
The the current move delay for the agent. By default, this just looks up the value in self.move_delays. However, this method allows children to implement move complex functionality (e.g., power-ups that affect an agent's speed).
304 def get_next_agent_index(self) -> int: 305 """ 306 Get the agent that moves next. 307 Do this by looking at the agents' tickets and choosing the one with the lowest ticket. 308 """ 309 310 next_index = -1 311 for (agent_index, ticket) in self.tickets.items(): 312 if ((next_index == -1) or (ticket.is_before(self.tickets[next_index]))): 313 next_index = agent_index 314 315 return next_index
Get the agent that moves next. Do this by looking at the agents' tickets and choosing the one with the lowest ticket.
317 def sprite_lookup(self, 318 sprite_sheet: pacai.core.spritesheet.SpriteSheet, 319 position: pacai.core.board.Position, 320 marker: pacai.core.board.Marker | None = None, 321 action: pacai.core.action.Action | None = None, 322 adjacency: pacai.core.board.AdjacencyString | None = None, 323 animation_key: str | None = None, 324 ) -> PIL.Image.Image: 325 """ 326 Lookup the proper sprite for a situation. 327 By default this just calls into the sprite sheet, 328 but children may override for more expressive functionality. 329 """ 330 331 return sprite_sheet.get_sprite(marker = marker, action = action, adjacency = adjacency, animation_key = animation_key)
Lookup the proper sprite for a situation. By default this just calls into the sprite sheet, but children may override for more expressive functionality.
333 def skip_draw(self, 334 marker: pacai.core.board.Marker, 335 position: pacai.core.board.Position, 336 static: bool = False, 337 ) -> bool: 338 """ Return true if this marker/position combination should not be drawn on the board. """ 339 340 return False
Return true if this marker/position combination should not be drawn on the board.
342 def get_static_positions(self) -> list[pacai.core.board.Position]: 343 """ Get a list of positions to draw on the board statically. """ 344 345 return []
Get a list of positions to draw on the board statically.
347 def get_static_text(self) -> list[pacai.core.font.BoardText]: 348 """ Get any static text to display on board positions. """ 349 350 return []
Get any static text to display on board positions.
352 def get_nonstatic_text(self) -> list[pacai.core.font.BoardText]: 353 """ Get any non-static text to display on board positions. """ 354 355 return []
Get any non-static text to display on board positions.
369 def to_dict(self) -> dict[str, typing.Any]: 370 data = vars(self).copy() 371 372 data['board'] = self.board.to_dict() 373 data['tickets'] = {agent_index: ticket.to_dict() for (agent_index, ticket) in sorted(self.tickets.items())} 374 data['agent_actions'] = {agent_index: [str(action) for action in actions] for (agent_index, actions) in self.agent_actions.items()} 375 376 return data
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.
378 @classmethod 379 def from_dict(cls, data: dict[str, typing.Any]) -> typing.Any: 380 data = data.copy() 381 382 data['board'] = pacai.core.board.Board.from_dict(data['board']) 383 data['tickets'] = {int(agent_index): pacai.core.ticket.Ticket.from_dict(ticket) for (agent_index, ticket) in data['tickets'].items()} 384 data['agent_actions'] = {int(agent_index): [pacai.core.action.Action(raw_action, safe = True) for raw_action in raw_actions] 385 for (agent_index, raw_actions) in data['agent_actions'].items()} 386 387 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.
389 def get_legal_actions(self, position: pacai.core.board.Position | None = None) -> list[pacai.core.action.Action]: 390 """ 391 Get the moves that the current agent is allowed to make. 392 Stopping is generally always considered a legal action (unless a game re-defines this behavior). 393 394 If a position is provided, it will override the current agent's position. 395 """ 396 397 # Stopping is generally safe. 398 actions = [pacai.core.action.STOP] 399 400 if (position is None): 401 if (self.agent_index == -1): 402 raise ValueError("Cannot get legal actions when no agent is active.") 403 404 position = self.get_agent_position() 405 406 # If the agent is not on the board, it can only stop. 407 if (position is None): 408 return actions 409 410 # Get moves to adjacent positions. 411 neighbor_moves = self.board.get_neighbors(position) 412 for (action, _) in neighbor_moves: 413 actions.append(action) 414 415 return actions
Get the moves that the current agent is allowed to make. Stopping is generally always considered a legal action (unless a game re-defines this behavior).
If a position is provided, it will override the current agent's position.
417@typing.runtime_checkable 418class AgentStateEvaluationFunction(typing.Protocol): 419 """ 420 A function that can be used to score a game state. 421 """ 422 423 def __call__(self, 424 state: GameState, 425 agent: typing.Any | None = None, 426 action: pacai.core.action.Action | None = None, 427 **kwargs: typing.Any) -> float: 428 """ 429 Compute a score for a state that the provided agent can use to decide actions. 430 The current state is the only required argument, the others are optional. 431 Passing the agent asking for this evaluation is a simple way to pass persistent state 432 (like pre-computed distances) from the agent to this function. 433 """
A function that can be used to score a game state.
1953def _no_init_or_replace_init(self, *args, **kwargs): 1954 cls = type(self) 1955 1956 if cls._is_protocol: 1957 raise TypeError('Protocols cannot be instantiated') 1958 1959 # Already using a custom `__init__`. No need to calculate correct 1960 # `__init__` to call. This can lead to RecursionError. See bpo-45121. 1961 if cls.__init__ is not _no_init_or_replace_init: 1962 return 1963 1964 # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`. 1965 # The first instantiation of the subclass will call `_no_init_or_replace_init` which 1966 # searches for a proper new `__init__` in the MRO. The new `__init__` 1967 # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent 1968 # instantiation of the protocol subclass will thus use the new 1969 # `__init__` and no longer call `_no_init_or_replace_init`. 1970 for base in cls.__mro__: 1971 init = base.__dict__.get('__init__', _no_init_or_replace_init) 1972 if init is not _no_init_or_replace_init: 1973 cls.__init__ = init 1974 break 1975 else: 1976 # should not happen 1977 cls.__init__ = object.__init__ 1978 1979 cls.__init__(self, *args, **kwargs)
435def base_eval( 436 state: GameState, 437 agent: typing.Any | None = None, 438 action: pacai.core.action.Action | None = None, 439 **kwargs: typing.Any) -> float: 440 """ The most basic evaluation function, which just uses the state's current score. """ 441 442 return float(state.score)
The most basic evaluation function, which just uses the state's current score.