backend.server
1import asyncio 2import json 3import logging 4import random 5from typing import Any, Dict, List, Optional, Tuple, Union 6 7from websockets.asyncio.server import ServerConnection, serve 8 9logging.basicConfig(level=logging.INFO, format="%(asctime)s - SERVER - %(levelname)s - %(message)s") 10 11 12class BattleshipServer: 13 """ 14 WebSocket server for the Battleship game. 15 Manages the game state, connects agents and a single viewer (frontend). 16 """ 17 18 def __init__(self) -> None: 19 """ 20 Initializes the game state and tracking variables. 21 """ 22 self.frontend_ws: Optional[ServerConnection] = None 23 self.agent1_ws: Optional[ServerConnection] = None 24 self.agent2_ws: Optional[ServerConnection] = None 25 26 self.size: int = 10 27 # 0 = Water, Ship Name = Ship 28 self.p1_ships: List[List[Union[int, str]]] = [[0] * self.size for _ in range(self.size)] 29 self.p2_ships: List[List[Union[int, str]]] = [[0] * self.size for _ in range(self.size)] 30 # 0 = Unknown, 1 = Miss, 2 = Hit 31 self.p1_shots: List[List[int]] = [[0] * self.size for _ in range(self.size)] 32 self.p2_shots: List[List[int]] = [[0] * self.size for _ in range(self.size)] 33 34 self.ship_fleet: Dict[str, int] = { 35 "Carrier": 5, 36 "Battleship": 4, 37 "Cruiser": 3, 38 "Submarine": 3, 39 "Destroyer": 2, 40 } 41 self.p1_health: Dict[str, int] = {} 42 self.p2_health: Dict[str, int] = {} 43 44 self.first_player_this_round: int = 1 45 self.current_turn: int = 1 46 self.running: bool = False 47 self.scores: Dict[int, int] = {1: 0, 2: 0} 48 49 async def start(self, host: str = "0.0.0.0", port: int = 8765) -> None: 50 """ 51 Starts the WebSocket server. 52 53 Args: 54 host (str): The host address to bind to. 55 port (int): The port to listen on. 56 """ 57 logging.info(f"Battleship Server started on ws://{host}:{port}") 58 async with serve(self.handle_client, host, port): 59 await asyncio.Future() 60 61 async def handle_client(self, websocket: ServerConnection) -> None: 62 """ 63 Handles incoming WebSocket connections and assigns them to a role. 64 65 Args: 66 websocket: The connecting WebSocket protocol instance. 67 """ 68 client_type: str = "Unknown" 69 try: 70 init_msg = await websocket.recv() 71 72 data: Dict[str, Any] = json.loads(init_msg) 73 type_val = data.get("client", "Unknown") 74 client_type = str(type_val) if type_val is not None else "Unknown" 75 76 if client_type == "frontend": 77 logging.info("Frontend connected.") 78 self.frontend_ws = websocket 79 await self.update_frontend() 80 await self.frontend_loop(websocket) 81 elif client_type == "agent": 82 if not self.agent1_ws: 83 self.agent1_ws = websocket 84 logging.info("Player 1 connected.") 85 await websocket.send(json.dumps({"type": "setup", "player_id": 1, "size": self.size})) 86 await self.check_start_conditions() 87 await self.agent_loop(websocket, 1) 88 elif not self.agent2_ws: 89 self.agent2_ws = websocket 90 logging.info("Player 2 connected.") 91 await websocket.send(json.dumps({"type": "setup", "player_id": 2, "size": self.size})) 92 await self.check_start_conditions() 93 await self.agent_loop(websocket, 2) 94 else: 95 await websocket.close() 96 except Exception as e: 97 logging.error(f"Error handling client ({client_type}): {e}") 98 finally: 99 if websocket == self.frontend_ws: 100 self.frontend_ws = None 101 elif websocket == self.agent1_ws: 102 self.agent1_ws = None 103 self.running = False 104 elif websocket == self.agent2_ws: 105 self.agent2_ws = None 106 self.running = False 107 108 async def frontend_loop(self, websocket: ServerConnection) -> None: 109 """ 110 Main loop for the frontend viewer connection. 111 112 Args: 113 websocket: The frontend WebSocket protocol instance. 114 """ 115 async for _ in websocket: 116 pass 117 118 async def agent_loop(self, websocket: ServerConnection, player_id: int) -> None: 119 """ 120 Main loop for an agent connection. 121 122 Args: 123 websocket: The agent's WebSocket protocol instance. 124 player_id: The ID of the player (1 or 2). 125 """ 126 async for message in websocket: 127 if not self.running or self.current_turn != player_id: 128 continue 129 try: 130 data: Dict[str, Any] = json.loads(message) 131 if data.get("action") == "fire": 132 x, y = data.get("x"), data.get("y") 133 if isinstance(x, int) and isinstance(y, int): 134 valid, hit = self.process_shot(player_id, x, y) 135 if valid: 136 await self.update_frontend() 137 await self.check_game_over() 138 if self.running: 139 if not hit: 140 self.current_turn = 3 - self.current_turn 141 await self.broadcast_state() 142 except Exception as e: 143 logging.error(f"Error processing move from Player {player_id}: {e}") 144 145 async def check_start_conditions(self) -> None: 146 """ 147 Checks if both agents are connected and starts a new round if needed. 148 """ 149 if self.agent1_ws and self.agent2_ws and not self.running: 150 self.running = True 151 self.current_turn = self.first_player_this_round 152 153 # Initialize empty boards 154 self.p1_ships = [[0] * self.size for _ in range(self.size)] 155 self.p2_ships = [[0] * self.size for _ in range(self.size)] 156 self.p1_shots = [[0] * self.size for _ in range(self.size)] 157 self.p2_shots = [[0] * self.size for _ in range(self.size)] 158 159 # Place ships and track health 160 self.p1_health = self.place_fleet(self.p1_ships) 161 self.p2_health = self.place_fleet(self.p2_ships) 162 163 await self.update_frontend() 164 await self.broadcast_state() 165 166 def place_fleet(self, board: List[List[Union[int, str]]]) -> Dict[str, int]: 167 r""" 168 Randomly places ships on the provided board. 169 170 Args: 171 board: The 10x10 matrix to place ships on. 172 173 Returns: 174 A dictionary tracking the health of each placed ship. 175 """ 176 health_tracker: Dict[str, int] = {} 177 for ship_name, length in self.ship_fleet.items(): 178 health_tracker[ship_name] = length 179 placed = False 180 while not placed: 181 x, y = ( 182 random.randint(0, self.size - 1), 183 random.randint(0, self.size - 1), 184 ) 185 horizontal = random.choice([True, False]) 186 187 # Check bounds 188 if horizontal and x + length > self.size: 189 continue 190 if not horizontal and y + length > self.size: 191 continue 192 193 # Check overlap 194 overlap = False 195 for i in range(length): 196 if horizontal: 197 if board[y][x + i] != 0: 198 overlap = True 199 break 200 else: 201 if board[y + i][x] != 0: 202 overlap = True 203 break 204 205 if not overlap: 206 for i in range(length): 207 if horizontal: 208 board[y][x + i] = ship_name 209 else: 210 board[y + i][x] = ship_name 211 placed = True 212 return health_tracker 213 214 def get_valid_actions(self, player_id: int) -> List[List[int]]: 215 """ 216 Calculates all valid target coordinates for a player. 217 218 Args: 219 player_id: The ID of the player (1 or 2). 220 221 Returns: 222 A list of [x, y] coordinates that have not been targeted yet. 223 """ 224 shots = self.p1_shots if player_id == 1 else self.p2_shots 225 actions: List[List[int]] = [] 226 for y in range(self.size): 227 for x in range(self.size): 228 if shots[y][x] == 0: 229 actions.append([x, y]) 230 return actions 231 232 def process_shot(self, player_id: int, x: int, y: int) -> Tuple[bool, bool]: 233 r""" 234 Processes an agent's firing action. 235 236 Given a coordinate $(x, y)$, where $x, y \in [0, 9]$, this method 237 updates the shot history $M_{shots}$ and checks for a hit on the 238 opponent's fleet $F$. 239 240 Args: 241 player_id: The ID of the player taking the shot. 242 x: The x-coordinate target. 243 y: The y-coordinate target. 244 245 Returns: 246 A tuple (valid_shot, was_hit). 247 """ 248 shots = self.p1_shots if player_id == 1 else self.p2_shots 249 enemy_ships = self.p2_ships if player_id == 1 else self.p1_ships 250 enemy_health = self.p2_health if player_id == 1 else self.p1_health 251 252 if not (0 <= x < self.size and 0 <= y < self.size) or shots[y][x] != 0: 253 return False, False # Invalid or already shot 254 255 target = enemy_ships[y][x] 256 is_hit = False 257 if isinstance(target, str): 258 shots[y][x] = 2 # Hit 259 is_hit = True 260 enemy_health[target] -= 1 261 if enemy_health[target] == 0: 262 logging.info(f"Player {player_id} SUNK the {target}!") 263 else: 264 shots[y][x] = 1 # Miss 265 266 return True, is_hit 267 268 async def check_game_over(self) -> None: 269 """ 270 Checks if the current game session has concluded. 271 """ 272 p1_dead = all(h == 0 for h in self.p1_health.values()) 273 p2_dead = all(h == 0 for h in self.p2_health.values()) 274 275 winner = None 276 if p1_dead: 277 winner = 2 278 elif p2_dead: 279 winner = 1 280 281 if winner: 282 self.scores[winner] += 1 283 await self.end_round(f"Player {winner} Wins!") 284 285 async def end_round(self, message: str) -> None: 286 """ 287 Finishes a game round and schedules a restart. 288 289 Args: 290 message: The result message to send to clients. 291 """ 292 self.running = False 293 payload = {"type": "game_over", "message": message} 294 if self.agent1_ws: 295 await self.agent1_ws.send(json.dumps(payload)) 296 if self.agent2_ws: 297 await self.agent2_ws.send(json.dumps(payload)) 298 await self.update_frontend() 299 300 await asyncio.sleep(3.0) 301 self.first_player_this_round = 3 - self.first_player_this_round 302 await self.check_start_conditions() 303 304 async def broadcast_state(self) -> None: 305 """Sends partial state information to each connected agent.""" 306 if self.agent1_ws: 307 await self.agent1_ws.send( 308 json.dumps( 309 { 310 "type": "state", 311 "current_turn": self.current_turn, 312 "my_ships": self.p1_ships, 313 "my_shots": self.p1_shots, 314 "valid_actions": self.get_valid_actions(1), 315 } 316 ) 317 ) 318 if self.agent2_ws: 319 await self.agent2_ws.send( 320 json.dumps( 321 { 322 "type": "state", 323 "current_turn": self.current_turn, 324 "my_ships": self.p2_ships, 325 "my_shots": self.p2_shots, 326 "valid_actions": self.get_valid_actions(2), 327 } 328 ) 329 ) 330 331 async def update_frontend(self) -> None: 332 """Sends the full game state to the frontend viewer.""" 333 if self.frontend_ws: 334 await self.frontend_ws.send( 335 json.dumps( 336 { 337 "type": "update", 338 "current_turn": self.current_turn, 339 "p1_ships": self.p1_ships, 340 "p2_ships": self.p2_ships, 341 "p1_shots": self.p1_shots, 342 "p2_shots": self.p2_shots, 343 "scores": self.scores, 344 "p1_connected": self.agent1_ws is not None, 345 "p2_connected": self.agent2_ws is not None, 346 } 347 ) 348 ) 349 350 351if __name__ == "__main__": 352 server = BattleshipServer() 353 asyncio.run(server.start())
13class BattleshipServer: 14 """ 15 WebSocket server for the Battleship game. 16 Manages the game state, connects agents and a single viewer (frontend). 17 """ 18 19 def __init__(self) -> None: 20 """ 21 Initializes the game state and tracking variables. 22 """ 23 self.frontend_ws: Optional[ServerConnection] = None 24 self.agent1_ws: Optional[ServerConnection] = None 25 self.agent2_ws: Optional[ServerConnection] = None 26 27 self.size: int = 10 28 # 0 = Water, Ship Name = Ship 29 self.p1_ships: List[List[Union[int, str]]] = [[0] * self.size for _ in range(self.size)] 30 self.p2_ships: List[List[Union[int, str]]] = [[0] * self.size for _ in range(self.size)] 31 # 0 = Unknown, 1 = Miss, 2 = Hit 32 self.p1_shots: List[List[int]] = [[0] * self.size for _ in range(self.size)] 33 self.p2_shots: List[List[int]] = [[0] * self.size for _ in range(self.size)] 34 35 self.ship_fleet: Dict[str, int] = { 36 "Carrier": 5, 37 "Battleship": 4, 38 "Cruiser": 3, 39 "Submarine": 3, 40 "Destroyer": 2, 41 } 42 self.p1_health: Dict[str, int] = {} 43 self.p2_health: Dict[str, int] = {} 44 45 self.first_player_this_round: int = 1 46 self.current_turn: int = 1 47 self.running: bool = False 48 self.scores: Dict[int, int] = {1: 0, 2: 0} 49 50 async def start(self, host: str = "0.0.0.0", port: int = 8765) -> None: 51 """ 52 Starts the WebSocket server. 53 54 Args: 55 host (str): The host address to bind to. 56 port (int): The port to listen on. 57 """ 58 logging.info(f"Battleship Server started on ws://{host}:{port}") 59 async with serve(self.handle_client, host, port): 60 await asyncio.Future() 61 62 async def handle_client(self, websocket: ServerConnection) -> None: 63 """ 64 Handles incoming WebSocket connections and assigns them to a role. 65 66 Args: 67 websocket: The connecting WebSocket protocol instance. 68 """ 69 client_type: str = "Unknown" 70 try: 71 init_msg = await websocket.recv() 72 73 data: Dict[str, Any] = json.loads(init_msg) 74 type_val = data.get("client", "Unknown") 75 client_type = str(type_val) if type_val is not None else "Unknown" 76 77 if client_type == "frontend": 78 logging.info("Frontend connected.") 79 self.frontend_ws = websocket 80 await self.update_frontend() 81 await self.frontend_loop(websocket) 82 elif client_type == "agent": 83 if not self.agent1_ws: 84 self.agent1_ws = websocket 85 logging.info("Player 1 connected.") 86 await websocket.send(json.dumps({"type": "setup", "player_id": 1, "size": self.size})) 87 await self.check_start_conditions() 88 await self.agent_loop(websocket, 1) 89 elif not self.agent2_ws: 90 self.agent2_ws = websocket 91 logging.info("Player 2 connected.") 92 await websocket.send(json.dumps({"type": "setup", "player_id": 2, "size": self.size})) 93 await self.check_start_conditions() 94 await self.agent_loop(websocket, 2) 95 else: 96 await websocket.close() 97 except Exception as e: 98 logging.error(f"Error handling client ({client_type}): {e}") 99 finally: 100 if websocket == self.frontend_ws: 101 self.frontend_ws = None 102 elif websocket == self.agent1_ws: 103 self.agent1_ws = None 104 self.running = False 105 elif websocket == self.agent2_ws: 106 self.agent2_ws = None 107 self.running = False 108 109 async def frontend_loop(self, websocket: ServerConnection) -> None: 110 """ 111 Main loop for the frontend viewer connection. 112 113 Args: 114 websocket: The frontend WebSocket protocol instance. 115 """ 116 async for _ in websocket: 117 pass 118 119 async def agent_loop(self, websocket: ServerConnection, player_id: int) -> None: 120 """ 121 Main loop for an agent connection. 122 123 Args: 124 websocket: The agent's WebSocket protocol instance. 125 player_id: The ID of the player (1 or 2). 126 """ 127 async for message in websocket: 128 if not self.running or self.current_turn != player_id: 129 continue 130 try: 131 data: Dict[str, Any] = json.loads(message) 132 if data.get("action") == "fire": 133 x, y = data.get("x"), data.get("y") 134 if isinstance(x, int) and isinstance(y, int): 135 valid, hit = self.process_shot(player_id, x, y) 136 if valid: 137 await self.update_frontend() 138 await self.check_game_over() 139 if self.running: 140 if not hit: 141 self.current_turn = 3 - self.current_turn 142 await self.broadcast_state() 143 except Exception as e: 144 logging.error(f"Error processing move from Player {player_id}: {e}") 145 146 async def check_start_conditions(self) -> None: 147 """ 148 Checks if both agents are connected and starts a new round if needed. 149 """ 150 if self.agent1_ws and self.agent2_ws and not self.running: 151 self.running = True 152 self.current_turn = self.first_player_this_round 153 154 # Initialize empty boards 155 self.p1_ships = [[0] * self.size for _ in range(self.size)] 156 self.p2_ships = [[0] * self.size for _ in range(self.size)] 157 self.p1_shots = [[0] * self.size for _ in range(self.size)] 158 self.p2_shots = [[0] * self.size for _ in range(self.size)] 159 160 # Place ships and track health 161 self.p1_health = self.place_fleet(self.p1_ships) 162 self.p2_health = self.place_fleet(self.p2_ships) 163 164 await self.update_frontend() 165 await self.broadcast_state() 166 167 def place_fleet(self, board: List[List[Union[int, str]]]) -> Dict[str, int]: 168 r""" 169 Randomly places ships on the provided board. 170 171 Args: 172 board: The 10x10 matrix to place ships on. 173 174 Returns: 175 A dictionary tracking the health of each placed ship. 176 """ 177 health_tracker: Dict[str, int] = {} 178 for ship_name, length in self.ship_fleet.items(): 179 health_tracker[ship_name] = length 180 placed = False 181 while not placed: 182 x, y = ( 183 random.randint(0, self.size - 1), 184 random.randint(0, self.size - 1), 185 ) 186 horizontal = random.choice([True, False]) 187 188 # Check bounds 189 if horizontal and x + length > self.size: 190 continue 191 if not horizontal and y + length > self.size: 192 continue 193 194 # Check overlap 195 overlap = False 196 for i in range(length): 197 if horizontal: 198 if board[y][x + i] != 0: 199 overlap = True 200 break 201 else: 202 if board[y + i][x] != 0: 203 overlap = True 204 break 205 206 if not overlap: 207 for i in range(length): 208 if horizontal: 209 board[y][x + i] = ship_name 210 else: 211 board[y + i][x] = ship_name 212 placed = True 213 return health_tracker 214 215 def get_valid_actions(self, player_id: int) -> List[List[int]]: 216 """ 217 Calculates all valid target coordinates for a player. 218 219 Args: 220 player_id: The ID of the player (1 or 2). 221 222 Returns: 223 A list of [x, y] coordinates that have not been targeted yet. 224 """ 225 shots = self.p1_shots if player_id == 1 else self.p2_shots 226 actions: List[List[int]] = [] 227 for y in range(self.size): 228 for x in range(self.size): 229 if shots[y][x] == 0: 230 actions.append([x, y]) 231 return actions 232 233 def process_shot(self, player_id: int, x: int, y: int) -> Tuple[bool, bool]: 234 r""" 235 Processes an agent's firing action. 236 237 Given a coordinate $(x, y)$, where $x, y \in [0, 9]$, this method 238 updates the shot history $M_{shots}$ and checks for a hit on the 239 opponent's fleet $F$. 240 241 Args: 242 player_id: The ID of the player taking the shot. 243 x: The x-coordinate target. 244 y: The y-coordinate target. 245 246 Returns: 247 A tuple (valid_shot, was_hit). 248 """ 249 shots = self.p1_shots if player_id == 1 else self.p2_shots 250 enemy_ships = self.p2_ships if player_id == 1 else self.p1_ships 251 enemy_health = self.p2_health if player_id == 1 else self.p1_health 252 253 if not (0 <= x < self.size and 0 <= y < self.size) or shots[y][x] != 0: 254 return False, False # Invalid or already shot 255 256 target = enemy_ships[y][x] 257 is_hit = False 258 if isinstance(target, str): 259 shots[y][x] = 2 # Hit 260 is_hit = True 261 enemy_health[target] -= 1 262 if enemy_health[target] == 0: 263 logging.info(f"Player {player_id} SUNK the {target}!") 264 else: 265 shots[y][x] = 1 # Miss 266 267 return True, is_hit 268 269 async def check_game_over(self) -> None: 270 """ 271 Checks if the current game session has concluded. 272 """ 273 p1_dead = all(h == 0 for h in self.p1_health.values()) 274 p2_dead = all(h == 0 for h in self.p2_health.values()) 275 276 winner = None 277 if p1_dead: 278 winner = 2 279 elif p2_dead: 280 winner = 1 281 282 if winner: 283 self.scores[winner] += 1 284 await self.end_round(f"Player {winner} Wins!") 285 286 async def end_round(self, message: str) -> None: 287 """ 288 Finishes a game round and schedules a restart. 289 290 Args: 291 message: The result message to send to clients. 292 """ 293 self.running = False 294 payload = {"type": "game_over", "message": message} 295 if self.agent1_ws: 296 await self.agent1_ws.send(json.dumps(payload)) 297 if self.agent2_ws: 298 await self.agent2_ws.send(json.dumps(payload)) 299 await self.update_frontend() 300 301 await asyncio.sleep(3.0) 302 self.first_player_this_round = 3 - self.first_player_this_round 303 await self.check_start_conditions() 304 305 async def broadcast_state(self) -> None: 306 """Sends partial state information to each connected agent.""" 307 if self.agent1_ws: 308 await self.agent1_ws.send( 309 json.dumps( 310 { 311 "type": "state", 312 "current_turn": self.current_turn, 313 "my_ships": self.p1_ships, 314 "my_shots": self.p1_shots, 315 "valid_actions": self.get_valid_actions(1), 316 } 317 ) 318 ) 319 if self.agent2_ws: 320 await self.agent2_ws.send( 321 json.dumps( 322 { 323 "type": "state", 324 "current_turn": self.current_turn, 325 "my_ships": self.p2_ships, 326 "my_shots": self.p2_shots, 327 "valid_actions": self.get_valid_actions(2), 328 } 329 ) 330 ) 331 332 async def update_frontend(self) -> None: 333 """Sends the full game state to the frontend viewer.""" 334 if self.frontend_ws: 335 await self.frontend_ws.send( 336 json.dumps( 337 { 338 "type": "update", 339 "current_turn": self.current_turn, 340 "p1_ships": self.p1_ships, 341 "p2_ships": self.p2_ships, 342 "p1_shots": self.p1_shots, 343 "p2_shots": self.p2_shots, 344 "scores": self.scores, 345 "p1_connected": self.agent1_ws is not None, 346 "p2_connected": self.agent2_ws is not None, 347 } 348 ) 349 )
WebSocket server for the Battleship game. Manages the game state, connects agents and a single viewer (frontend).
19 def __init__(self) -> None: 20 """ 21 Initializes the game state and tracking variables. 22 """ 23 self.frontend_ws: Optional[ServerConnection] = None 24 self.agent1_ws: Optional[ServerConnection] = None 25 self.agent2_ws: Optional[ServerConnection] = None 26 27 self.size: int = 10 28 # 0 = Water, Ship Name = Ship 29 self.p1_ships: List[List[Union[int, str]]] = [[0] * self.size for _ in range(self.size)] 30 self.p2_ships: List[List[Union[int, str]]] = [[0] * self.size for _ in range(self.size)] 31 # 0 = Unknown, 1 = Miss, 2 = Hit 32 self.p1_shots: List[List[int]] = [[0] * self.size for _ in range(self.size)] 33 self.p2_shots: List[List[int]] = [[0] * self.size for _ in range(self.size)] 34 35 self.ship_fleet: Dict[str, int] = { 36 "Carrier": 5, 37 "Battleship": 4, 38 "Cruiser": 3, 39 "Submarine": 3, 40 "Destroyer": 2, 41 } 42 self.p1_health: Dict[str, int] = {} 43 self.p2_health: Dict[str, int] = {} 44 45 self.first_player_this_round: int = 1 46 self.current_turn: int = 1 47 self.running: bool = False 48 self.scores: Dict[int, int] = {1: 0, 2: 0}
Initializes the game state and tracking variables.
50 async def start(self, host: str = "0.0.0.0", port: int = 8765) -> None: 51 """ 52 Starts the WebSocket server. 53 54 Args: 55 host (str): The host address to bind to. 56 port (int): The port to listen on. 57 """ 58 logging.info(f"Battleship Server started on ws://{host}:{port}") 59 async with serve(self.handle_client, host, port): 60 await asyncio.Future()
Starts the WebSocket server.
Args: host (str): The host address to bind to. port (int): The port to listen on.
62 async def handle_client(self, websocket: ServerConnection) -> None: 63 """ 64 Handles incoming WebSocket connections and assigns them to a role. 65 66 Args: 67 websocket: The connecting WebSocket protocol instance. 68 """ 69 client_type: str = "Unknown" 70 try: 71 init_msg = await websocket.recv() 72 73 data: Dict[str, Any] = json.loads(init_msg) 74 type_val = data.get("client", "Unknown") 75 client_type = str(type_val) if type_val is not None else "Unknown" 76 77 if client_type == "frontend": 78 logging.info("Frontend connected.") 79 self.frontend_ws = websocket 80 await self.update_frontend() 81 await self.frontend_loop(websocket) 82 elif client_type == "agent": 83 if not self.agent1_ws: 84 self.agent1_ws = websocket 85 logging.info("Player 1 connected.") 86 await websocket.send(json.dumps({"type": "setup", "player_id": 1, "size": self.size})) 87 await self.check_start_conditions() 88 await self.agent_loop(websocket, 1) 89 elif not self.agent2_ws: 90 self.agent2_ws = websocket 91 logging.info("Player 2 connected.") 92 await websocket.send(json.dumps({"type": "setup", "player_id": 2, "size": self.size})) 93 await self.check_start_conditions() 94 await self.agent_loop(websocket, 2) 95 else: 96 await websocket.close() 97 except Exception as e: 98 logging.error(f"Error handling client ({client_type}): {e}") 99 finally: 100 if websocket == self.frontend_ws: 101 self.frontend_ws = None 102 elif websocket == self.agent1_ws: 103 self.agent1_ws = None 104 self.running = False 105 elif websocket == self.agent2_ws: 106 self.agent2_ws = None 107 self.running = False
Handles incoming WebSocket connections and assigns them to a role.
Args: websocket: The connecting WebSocket protocol instance.
109 async def frontend_loop(self, websocket: ServerConnection) -> None: 110 """ 111 Main loop for the frontend viewer connection. 112 113 Args: 114 websocket: The frontend WebSocket protocol instance. 115 """ 116 async for _ in websocket: 117 pass
Main loop for the frontend viewer connection.
Args: websocket: The frontend WebSocket protocol instance.
119 async def agent_loop(self, websocket: ServerConnection, player_id: int) -> None: 120 """ 121 Main loop for an agent connection. 122 123 Args: 124 websocket: The agent's WebSocket protocol instance. 125 player_id: The ID of the player (1 or 2). 126 """ 127 async for message in websocket: 128 if not self.running or self.current_turn != player_id: 129 continue 130 try: 131 data: Dict[str, Any] = json.loads(message) 132 if data.get("action") == "fire": 133 x, y = data.get("x"), data.get("y") 134 if isinstance(x, int) and isinstance(y, int): 135 valid, hit = self.process_shot(player_id, x, y) 136 if valid: 137 await self.update_frontend() 138 await self.check_game_over() 139 if self.running: 140 if not hit: 141 self.current_turn = 3 - self.current_turn 142 await self.broadcast_state() 143 except Exception as e: 144 logging.error(f"Error processing move from Player {player_id}: {e}")
Main loop for an agent connection.
Args: websocket: The agent's WebSocket protocol instance. player_id: The ID of the player (1 or 2).
146 async def check_start_conditions(self) -> None: 147 """ 148 Checks if both agents are connected and starts a new round if needed. 149 """ 150 if self.agent1_ws and self.agent2_ws and not self.running: 151 self.running = True 152 self.current_turn = self.first_player_this_round 153 154 # Initialize empty boards 155 self.p1_ships = [[0] * self.size for _ in range(self.size)] 156 self.p2_ships = [[0] * self.size for _ in range(self.size)] 157 self.p1_shots = [[0] * self.size for _ in range(self.size)] 158 self.p2_shots = [[0] * self.size for _ in range(self.size)] 159 160 # Place ships and track health 161 self.p1_health = self.place_fleet(self.p1_ships) 162 self.p2_health = self.place_fleet(self.p2_ships) 163 164 await self.update_frontend() 165 await self.broadcast_state()
Checks if both agents are connected and starts a new round if needed.
167 def place_fleet(self, board: List[List[Union[int, str]]]) -> Dict[str, int]: 168 r""" 169 Randomly places ships on the provided board. 170 171 Args: 172 board: The 10x10 matrix to place ships on. 173 174 Returns: 175 A dictionary tracking the health of each placed ship. 176 """ 177 health_tracker: Dict[str, int] = {} 178 for ship_name, length in self.ship_fleet.items(): 179 health_tracker[ship_name] = length 180 placed = False 181 while not placed: 182 x, y = ( 183 random.randint(0, self.size - 1), 184 random.randint(0, self.size - 1), 185 ) 186 horizontal = random.choice([True, False]) 187 188 # Check bounds 189 if horizontal and x + length > self.size: 190 continue 191 if not horizontal and y + length > self.size: 192 continue 193 194 # Check overlap 195 overlap = False 196 for i in range(length): 197 if horizontal: 198 if board[y][x + i] != 0: 199 overlap = True 200 break 201 else: 202 if board[y + i][x] != 0: 203 overlap = True 204 break 205 206 if not overlap: 207 for i in range(length): 208 if horizontal: 209 board[y][x + i] = ship_name 210 else: 211 board[y + i][x] = ship_name 212 placed = True 213 return health_tracker
Randomly places ships on the provided board.
Args: board: The 10x10 matrix to place ships on.
Returns: A dictionary tracking the health of each placed ship.
215 def get_valid_actions(self, player_id: int) -> List[List[int]]: 216 """ 217 Calculates all valid target coordinates for a player. 218 219 Args: 220 player_id: The ID of the player (1 or 2). 221 222 Returns: 223 A list of [x, y] coordinates that have not been targeted yet. 224 """ 225 shots = self.p1_shots if player_id == 1 else self.p2_shots 226 actions: List[List[int]] = [] 227 for y in range(self.size): 228 for x in range(self.size): 229 if shots[y][x] == 0: 230 actions.append([x, y]) 231 return actions
Calculates all valid target coordinates for a player.
Args: player_id: The ID of the player (1 or 2).
Returns: A list of [x, y] coordinates that have not been targeted yet.
233 def process_shot(self, player_id: int, x: int, y: int) -> Tuple[bool, bool]: 234 r""" 235 Processes an agent's firing action. 236 237 Given a coordinate $(x, y)$, where $x, y \in [0, 9]$, this method 238 updates the shot history $M_{shots}$ and checks for a hit on the 239 opponent's fleet $F$. 240 241 Args: 242 player_id: The ID of the player taking the shot. 243 x: The x-coordinate target. 244 y: The y-coordinate target. 245 246 Returns: 247 A tuple (valid_shot, was_hit). 248 """ 249 shots = self.p1_shots if player_id == 1 else self.p2_shots 250 enemy_ships = self.p2_ships if player_id == 1 else self.p1_ships 251 enemy_health = self.p2_health if player_id == 1 else self.p1_health 252 253 if not (0 <= x < self.size and 0 <= y < self.size) or shots[y][x] != 0: 254 return False, False # Invalid or already shot 255 256 target = enemy_ships[y][x] 257 is_hit = False 258 if isinstance(target, str): 259 shots[y][x] = 2 # Hit 260 is_hit = True 261 enemy_health[target] -= 1 262 if enemy_health[target] == 0: 263 logging.info(f"Player {player_id} SUNK the {target}!") 264 else: 265 shots[y][x] = 1 # Miss 266 267 return True, is_hit
Processes an agent's firing action.
Given a coordinate $(x, y)$, where $x, y \in [0, 9]$, this method updates the shot history $M_{shots}$ and checks for a hit on the opponent's fleet $F$.
Args: player_id: The ID of the player taking the shot. x: The x-coordinate target. y: The y-coordinate target.
Returns: A tuple (valid_shot, was_hit).
269 async def check_game_over(self) -> None: 270 """ 271 Checks if the current game session has concluded. 272 """ 273 p1_dead = all(h == 0 for h in self.p1_health.values()) 274 p2_dead = all(h == 0 for h in self.p2_health.values()) 275 276 winner = None 277 if p1_dead: 278 winner = 2 279 elif p2_dead: 280 winner = 1 281 282 if winner: 283 self.scores[winner] += 1 284 await self.end_round(f"Player {winner} Wins!")
Checks if the current game session has concluded.
286 async def end_round(self, message: str) -> None: 287 """ 288 Finishes a game round and schedules a restart. 289 290 Args: 291 message: The result message to send to clients. 292 """ 293 self.running = False 294 payload = {"type": "game_over", "message": message} 295 if self.agent1_ws: 296 await self.agent1_ws.send(json.dumps(payload)) 297 if self.agent2_ws: 298 await self.agent2_ws.send(json.dumps(payload)) 299 await self.update_frontend() 300 301 await asyncio.sleep(3.0) 302 self.first_player_this_round = 3 - self.first_player_this_round 303 await self.check_start_conditions()
Finishes a game round and schedules a restart.
Args: message: The result message to send to clients.
305 async def broadcast_state(self) -> None: 306 """Sends partial state information to each connected agent.""" 307 if self.agent1_ws: 308 await self.agent1_ws.send( 309 json.dumps( 310 { 311 "type": "state", 312 "current_turn": self.current_turn, 313 "my_ships": self.p1_ships, 314 "my_shots": self.p1_shots, 315 "valid_actions": self.get_valid_actions(1), 316 } 317 ) 318 ) 319 if self.agent2_ws: 320 await self.agent2_ws.send( 321 json.dumps( 322 { 323 "type": "state", 324 "current_turn": self.current_turn, 325 "my_ships": self.p2_ships, 326 "my_shots": self.p2_shots, 327 "valid_actions": self.get_valid_actions(2), 328 } 329 ) 330 )
Sends partial state information to each connected agent.
332 async def update_frontend(self) -> None: 333 """Sends the full game state to the frontend viewer.""" 334 if self.frontend_ws: 335 await self.frontend_ws.send( 336 json.dumps( 337 { 338 "type": "update", 339 "current_turn": self.current_turn, 340 "p1_ships": self.p1_ships, 341 "p2_ships": self.p2_ships, 342 "p1_shots": self.p1_shots, 343 "p2_shots": self.p2_shots, 344 "scores": self.scores, 345 "p1_connected": self.agent1_ws is not None, 346 "p2_connected": self.agent2_ws is not None, 347 } 348 ) 349 )
Sends the full game state to the frontend viewer.