backend.server
1import asyncio 2import json 3import logging 4from typing import Any, Dict, List, Optional 5 6from websockets.asyncio.server import ServerConnection, serve 7 8logging.basicConfig( 9 level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" 10) 11 12 13class Connect4Server: 14 """ 15 Connect Four Server that manages game state, players, and communication. 16 """ 17 18 def __init__(self) -> None: 19 """ 20 Initializes the Connect4Server with default game settings and state. 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.rows: int = 6 27 self.cols: int = 7 28 self.board: List[List[int]] = [ 29 [0 for _ in range(self.cols)] for _ in range(self.rows) 30 ] 31 32 self.first_player_this_round: int = 1 33 self.current_turn: int = 1 34 self.running: bool = False 35 36 self.scores: Dict[int, int] = {1: 0, 2: 0} 37 38 async def start(self, host: str = "0.0.0.0", port: int = 8765) -> None: 39 """ 40 Starts the Connect Four WebSocket server. 41 42 Args: 43 host: The host address to bind the server to. 44 port: The port number to listen on. 45 """ 46 logging.info(f"Connect Four Server started on ws://{host}:{port}") 47 async with serve(self.handle_client, host, port): 48 await asyncio.Future() 49 50 async def handle_client( 51 self, websocket: ServerConnection 52 ) -> None: 53 """ 54 Handles incoming WebSocket connections and routes them based on client type. 55 56 Args: 57 websocket: The WebSocket connection object. 58 """ 59 client_type: str = "Unknown" 60 try: 61 init_msg = await websocket.recv() 62 if isinstance(init_msg, bytes): 63 init_msg = init_msg.decode("utf-8") 64 data: Dict[str, Any] = json.loads(init_msg) 65 client_type = data.get("client", "Unknown") 66 67 if client_type == "frontend": 68 logging.info("Frontend connected.") 69 self.frontend_ws = websocket 70 await self.update_frontend() 71 await self.frontend_loop(websocket) 72 elif client_type == "agent": 73 if not self.agent1_ws: 74 self.agent1_ws = websocket 75 logging.info("Player 1 connected.") 76 try: 77 await websocket.send( 78 json.dumps({"type": "setup", "player_id": 1}) 79 ) 80 except Exception: 81 self.agent1_ws = None 82 return 83 await self.check_start_conditions() 84 await self.agent_loop(websocket, 1) 85 elif not self.agent2_ws: 86 self.agent2_ws = websocket 87 logging.info("Player 2 connected.") 88 try: 89 await websocket.send( 90 json.dumps({"type": "setup", "player_id": 2}) 91 ) 92 except Exception: 93 self.agent2_ws = None 94 return 95 await self.check_start_conditions() 96 await self.agent_loop(websocket, 2) 97 else: 98 logging.warning("A 3rd agent tried to connect. Rejected.") 99 await websocket.close() 100 except Exception as e: 101 logging.error(f"Error handling client {client_type}: {e}") 102 finally: 103 if websocket == self.frontend_ws: 104 self.frontend_ws = None 105 elif websocket == self.agent1_ws: 106 self.agent1_ws = None 107 self.running = False 108 logging.info("Player 1 disconnected. Pausing game.") 109 await self.update_frontend() 110 elif websocket == self.agent2_ws: 111 self.agent2_ws = None 112 self.running = False 113 logging.info("Player 2 disconnected. Pausing game.") 114 await self.update_frontend() 115 116 async def frontend_loop( 117 self, websocket: ServerConnection 118 ) -> None: 119 """ 120 Keeps the frontend connection alive. 121 122 Args: 123 websocket: The frontend WebSocket connection object. 124 """ 125 async for _ in websocket: 126 pass # Frontend is view-only for now 127 128 async def agent_loop( 129 self, websocket: ServerConnection, player_id: int 130 ) -> None: 131 """ 132 Main loop for communicating with game agents. 133 134 Args: 135 websocket: The agent WebSocket connection object. 136 player_id: The ID of the player (1 or 2). 137 """ 138 async for message in websocket: 139 if not self.running or self.current_turn != player_id: 140 continue 141 try: 142 if isinstance(message, bytes): 143 message = message.decode("utf-8") 144 data: Dict[str, Any] = json.loads(message) 145 if data.get("action") == "move" and isinstance(data.get("column"), int): 146 col: int = data["column"] 147 row: Optional[int] = self.process_move(player_id, col) 148 if row is not None: 149 await self.update_frontend() 150 if await self.check_game_over(row, col): 151 continue # Round ended, don't swap turns here 152 153 self.current_turn = 3 - self.current_turn 154 await self.broadcast_state() 155 except Exception as e: 156 logging.error(f"Error processing move for Player {player_id}: {e}") 157 158 async def check_start_conditions(self) -> None: 159 """ 160 Checks if both agents are connected and starts the game if they are. 161 """ 162 if self.agent1_ws and self.agent2_ws and not self.running: 163 logging.info(f"Both agents connected. Starting round. Player {self.first_player_this_round} goes first.") 164 self.running = True 165 self.board = [[0 for _ in range(self.cols)] for _ in range(self.rows)] 166 self.current_turn = self.first_player_this_round 167 await self.update_frontend() 168 await self.broadcast_state() 169 170 def get_valid_actions(self) -> List[int]: 171 """ 172 Returns a list of column indices [0-6] that are not full. 173 174 Returns: 175 List of valid column indices. 176 """ 177 return [c for c in range(self.cols) if self.board[0][c] == 0] 178 179 def process_move(self, player_id: int, col: int) -> Optional[int]: 180 """ 181 Processes a move for a player. 182 183 Args: 184 player_id: The ID of the player making the move. 185 col: The column index where the piece is dropped. 186 187 Returns: 188 The row index where the piece landed, or None if the move was invalid. 189 """ 190 if col not in self.get_valid_actions(): 191 logging.warning(f"Player {player_id} attempted invalid move in column {col}") 192 return None 193 194 # Gravity: drop the piece to the lowest available row 195 for r in range(self.rows - 1, -1, -1): 196 if self.board[r][col] == 0: 197 self.board[r][col] = player_id 198 return r 199 return None 200 201 async def check_game_over(self, last_row: int, last_col: int) -> bool: 202 """ 203 Checks if the game has ended in a win or draw. 204 205 Args: 206 last_row: The row index of the last move. 207 last_col: The column index of the last move. 208 209 Returns: 210 True if the game is over, False otherwise. 211 """ 212 winner: int = self.check_win(last_row, last_col) 213 valid_actions: List[int] = self.get_valid_actions() 214 215 if winner: 216 logging.info(f"Player {winner} wins the round!") 217 self.scores[winner] += 1 218 await self.end_round(f"Player {winner} Wins!") 219 return True 220 elif not valid_actions: 221 logging.info("Round ended in a Draw.") 222 await self.end_round("Draw!") 223 return True 224 return False 225 226 def check_win(self, r: int, c: int) -> int: 227 """ 228 Checks for 4 in a row intersecting the last move (r, c). 229 230 Args: 231 r: Row index of the last move. 232 c: Column index of the last move. 233 234 Returns: 235 The ID of the winning player, or 0 if no win was found. 236 """ 237 b = self.board 238 p = b[r][c] 239 if p == 0: 240 return 0 241 242 directions = [ 243 (0, 1), # Horizontal 244 (1, 0), # Vertical 245 (1, 1), # Diagonal / 246 (1, -1), # Diagonal \ 247 ] 248 249 for dr, dc in directions: 250 count = 1 251 # Check in positive direction 252 for i in range(1, 4): 253 nr, nc = r + dr * i, c + dc * i 254 if 0 <= nr < self.rows and 0 <= nc < self.cols and b[nr][nc] == p: 255 count += 1 256 else: 257 break 258 # Check in negative direction 259 for i in range(1, 4): 260 nr, nc = r - dr * i, c - dc * i 261 if 0 <= nr < self.rows and 0 <= nc < self.cols and b[nr][nc] == p: 262 count += 1 263 else: 264 break 265 if count >= 4: 266 return p 267 return 0 268 269 async def end_round(self, message: str) -> None: 270 """ 271 Notifies agents of the outcome, swaps the starting player, and restarts. 272 273 Args: 274 message: Game outcome message. 275 """ 276 self.running = False 277 payload: Dict[str, Any] = { 278 "type": "game_over", 279 "message": message, 280 "scores": self.scores, 281 "board": self.board, 282 } 283 await self.broadcast_to_agents(payload) 284 await self.update_frontend(game_over_msg=message) 285 286 # Pause briefly so humans can see the winning move on the UI 287 await asyncio.sleep(2.0) 288 289 # Swap who goes first and restart automatically 290 self.first_player_this_round = 3 - self.first_player_this_round 291 # Re-check connections after sleep 292 if self.agent1_ws and self.agent2_ws: 293 await self.check_start_conditions() 294 else: 295 logging.info("Round ended and an agent disconnected. Waiting for players...") 296 297 async def broadcast_to_agents(self, payload: Dict[str, Any]) -> None: 298 """ 299 Sends a message to both connected agents. 300 301 Args: 302 payload: The message to send. 303 """ 304 msg: str = json.dumps(payload) 305 # Handle agent1_ws 306 if self.agent1_ws: 307 try: 308 await self.agent1_ws.send(msg) 309 except Exception: 310 self.agent1_ws = None 311 logging.info("Player 1 disconnected during broadcast.") 312 # Handle agent2_ws 313 if self.agent2_ws: 314 try: 315 await self.agent2_ws.send(msg) 316 except Exception: 317 self.agent2_ws = None 318 logging.info("Player 2 disconnected during broadcast.") 319 320 async def broadcast_state(self) -> None: 321 """ 322 Sends the board and valid actions to BOTH players. 323 """ 324 payload: Dict[str, Any] = { 325 "type": "state", 326 "board": self.board, 327 "valid_actions": self.get_valid_actions(), 328 "current_turn": self.current_turn, 329 } 330 await self.broadcast_to_agents(payload) 331 332 async def update_frontend(self, game_over_msg: Optional[str] = None) -> None: 333 """ 334 Sends current game state to the frontend. 335 336 Args: 337 game_over_msg: Optional game over message to display on the frontend. 338 """ 339 if self.frontend_ws: 340 payload: Dict[str, Any] = { 341 "type": "update", 342 "board": self.board, 343 "scores": self.scores, 344 "current_turn": self.current_turn, 345 "p1_connected": self.agent1_ws is not None, 346 "p2_connected": self.agent2_ws is not None, 347 "game_over": game_over_msg, 348 } 349 try: 350 await self.frontend_ws.send(json.dumps(payload)) 351 except Exception: 352 self.frontend_ws = None 353 logging.info("Frontend disconnected.") 354 355 356if __name__ == "__main__": 357 server = Connect4Server() 358 asyncio.run(server.start())
14class Connect4Server: 15 """ 16 Connect Four Server that manages game state, players, and communication. 17 """ 18 19 def __init__(self) -> None: 20 """ 21 Initializes the Connect4Server with default game settings and state. 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.rows: int = 6 28 self.cols: int = 7 29 self.board: List[List[int]] = [ 30 [0 for _ in range(self.cols)] for _ in range(self.rows) 31 ] 32 33 self.first_player_this_round: int = 1 34 self.current_turn: int = 1 35 self.running: bool = False 36 37 self.scores: Dict[int, int] = {1: 0, 2: 0} 38 39 async def start(self, host: str = "0.0.0.0", port: int = 8765) -> None: 40 """ 41 Starts the Connect Four WebSocket server. 42 43 Args: 44 host: The host address to bind the server to. 45 port: The port number to listen on. 46 """ 47 logging.info(f"Connect Four Server started on ws://{host}:{port}") 48 async with serve(self.handle_client, host, port): 49 await asyncio.Future() 50 51 async def handle_client( 52 self, websocket: ServerConnection 53 ) -> None: 54 """ 55 Handles incoming WebSocket connections and routes them based on client type. 56 57 Args: 58 websocket: The WebSocket connection object. 59 """ 60 client_type: str = "Unknown" 61 try: 62 init_msg = await websocket.recv() 63 if isinstance(init_msg, bytes): 64 init_msg = init_msg.decode("utf-8") 65 data: Dict[str, Any] = json.loads(init_msg) 66 client_type = data.get("client", "Unknown") 67 68 if client_type == "frontend": 69 logging.info("Frontend connected.") 70 self.frontend_ws = websocket 71 await self.update_frontend() 72 await self.frontend_loop(websocket) 73 elif client_type == "agent": 74 if not self.agent1_ws: 75 self.agent1_ws = websocket 76 logging.info("Player 1 connected.") 77 try: 78 await websocket.send( 79 json.dumps({"type": "setup", "player_id": 1}) 80 ) 81 except Exception: 82 self.agent1_ws = None 83 return 84 await self.check_start_conditions() 85 await self.agent_loop(websocket, 1) 86 elif not self.agent2_ws: 87 self.agent2_ws = websocket 88 logging.info("Player 2 connected.") 89 try: 90 await websocket.send( 91 json.dumps({"type": "setup", "player_id": 2}) 92 ) 93 except Exception: 94 self.agent2_ws = None 95 return 96 await self.check_start_conditions() 97 await self.agent_loop(websocket, 2) 98 else: 99 logging.warning("A 3rd agent tried to connect. Rejected.") 100 await websocket.close() 101 except Exception as e: 102 logging.error(f"Error handling client {client_type}: {e}") 103 finally: 104 if websocket == self.frontend_ws: 105 self.frontend_ws = None 106 elif websocket == self.agent1_ws: 107 self.agent1_ws = None 108 self.running = False 109 logging.info("Player 1 disconnected. Pausing game.") 110 await self.update_frontend() 111 elif websocket == self.agent2_ws: 112 self.agent2_ws = None 113 self.running = False 114 logging.info("Player 2 disconnected. Pausing game.") 115 await self.update_frontend() 116 117 async def frontend_loop( 118 self, websocket: ServerConnection 119 ) -> None: 120 """ 121 Keeps the frontend connection alive. 122 123 Args: 124 websocket: The frontend WebSocket connection object. 125 """ 126 async for _ in websocket: 127 pass # Frontend is view-only for now 128 129 async def agent_loop( 130 self, websocket: ServerConnection, player_id: int 131 ) -> None: 132 """ 133 Main loop for communicating with game agents. 134 135 Args: 136 websocket: The agent WebSocket connection object. 137 player_id: The ID of the player (1 or 2). 138 """ 139 async for message in websocket: 140 if not self.running or self.current_turn != player_id: 141 continue 142 try: 143 if isinstance(message, bytes): 144 message = message.decode("utf-8") 145 data: Dict[str, Any] = json.loads(message) 146 if data.get("action") == "move" and isinstance(data.get("column"), int): 147 col: int = data["column"] 148 row: Optional[int] = self.process_move(player_id, col) 149 if row is not None: 150 await self.update_frontend() 151 if await self.check_game_over(row, col): 152 continue # Round ended, don't swap turns here 153 154 self.current_turn = 3 - self.current_turn 155 await self.broadcast_state() 156 except Exception as e: 157 logging.error(f"Error processing move for Player {player_id}: {e}") 158 159 async def check_start_conditions(self) -> None: 160 """ 161 Checks if both agents are connected and starts the game if they are. 162 """ 163 if self.agent1_ws and self.agent2_ws and not self.running: 164 logging.info(f"Both agents connected. Starting round. Player {self.first_player_this_round} goes first.") 165 self.running = True 166 self.board = [[0 for _ in range(self.cols)] for _ in range(self.rows)] 167 self.current_turn = self.first_player_this_round 168 await self.update_frontend() 169 await self.broadcast_state() 170 171 def get_valid_actions(self) -> List[int]: 172 """ 173 Returns a list of column indices [0-6] that are not full. 174 175 Returns: 176 List of valid column indices. 177 """ 178 return [c for c in range(self.cols) if self.board[0][c] == 0] 179 180 def process_move(self, player_id: int, col: int) -> Optional[int]: 181 """ 182 Processes a move for a player. 183 184 Args: 185 player_id: The ID of the player making the move. 186 col: The column index where the piece is dropped. 187 188 Returns: 189 The row index where the piece landed, or None if the move was invalid. 190 """ 191 if col not in self.get_valid_actions(): 192 logging.warning(f"Player {player_id} attempted invalid move in column {col}") 193 return None 194 195 # Gravity: drop the piece to the lowest available row 196 for r in range(self.rows - 1, -1, -1): 197 if self.board[r][col] == 0: 198 self.board[r][col] = player_id 199 return r 200 return None 201 202 async def check_game_over(self, last_row: int, last_col: int) -> bool: 203 """ 204 Checks if the game has ended in a win or draw. 205 206 Args: 207 last_row: The row index of the last move. 208 last_col: The column index of the last move. 209 210 Returns: 211 True if the game is over, False otherwise. 212 """ 213 winner: int = self.check_win(last_row, last_col) 214 valid_actions: List[int] = self.get_valid_actions() 215 216 if winner: 217 logging.info(f"Player {winner} wins the round!") 218 self.scores[winner] += 1 219 await self.end_round(f"Player {winner} Wins!") 220 return True 221 elif not valid_actions: 222 logging.info("Round ended in a Draw.") 223 await self.end_round("Draw!") 224 return True 225 return False 226 227 def check_win(self, r: int, c: int) -> int: 228 """ 229 Checks for 4 in a row intersecting the last move (r, c). 230 231 Args: 232 r: Row index of the last move. 233 c: Column index of the last move. 234 235 Returns: 236 The ID of the winning player, or 0 if no win was found. 237 """ 238 b = self.board 239 p = b[r][c] 240 if p == 0: 241 return 0 242 243 directions = [ 244 (0, 1), # Horizontal 245 (1, 0), # Vertical 246 (1, 1), # Diagonal / 247 (1, -1), # Diagonal \ 248 ] 249 250 for dr, dc in directions: 251 count = 1 252 # Check in positive direction 253 for i in range(1, 4): 254 nr, nc = r + dr * i, c + dc * i 255 if 0 <= nr < self.rows and 0 <= nc < self.cols and b[nr][nc] == p: 256 count += 1 257 else: 258 break 259 # Check in negative direction 260 for i in range(1, 4): 261 nr, nc = r - dr * i, c - dc * i 262 if 0 <= nr < self.rows and 0 <= nc < self.cols and b[nr][nc] == p: 263 count += 1 264 else: 265 break 266 if count >= 4: 267 return p 268 return 0 269 270 async def end_round(self, message: str) -> None: 271 """ 272 Notifies agents of the outcome, swaps the starting player, and restarts. 273 274 Args: 275 message: Game outcome message. 276 """ 277 self.running = False 278 payload: Dict[str, Any] = { 279 "type": "game_over", 280 "message": message, 281 "scores": self.scores, 282 "board": self.board, 283 } 284 await self.broadcast_to_agents(payload) 285 await self.update_frontend(game_over_msg=message) 286 287 # Pause briefly so humans can see the winning move on the UI 288 await asyncio.sleep(2.0) 289 290 # Swap who goes first and restart automatically 291 self.first_player_this_round = 3 - self.first_player_this_round 292 # Re-check connections after sleep 293 if self.agent1_ws and self.agent2_ws: 294 await self.check_start_conditions() 295 else: 296 logging.info("Round ended and an agent disconnected. Waiting for players...") 297 298 async def broadcast_to_agents(self, payload: Dict[str, Any]) -> None: 299 """ 300 Sends a message to both connected agents. 301 302 Args: 303 payload: The message to send. 304 """ 305 msg: str = json.dumps(payload) 306 # Handle agent1_ws 307 if self.agent1_ws: 308 try: 309 await self.agent1_ws.send(msg) 310 except Exception: 311 self.agent1_ws = None 312 logging.info("Player 1 disconnected during broadcast.") 313 # Handle agent2_ws 314 if self.agent2_ws: 315 try: 316 await self.agent2_ws.send(msg) 317 except Exception: 318 self.agent2_ws = None 319 logging.info("Player 2 disconnected during broadcast.") 320 321 async def broadcast_state(self) -> None: 322 """ 323 Sends the board and valid actions to BOTH players. 324 """ 325 payload: Dict[str, Any] = { 326 "type": "state", 327 "board": self.board, 328 "valid_actions": self.get_valid_actions(), 329 "current_turn": self.current_turn, 330 } 331 await self.broadcast_to_agents(payload) 332 333 async def update_frontend(self, game_over_msg: Optional[str] = None) -> None: 334 """ 335 Sends current game state to the frontend. 336 337 Args: 338 game_over_msg: Optional game over message to display on the frontend. 339 """ 340 if self.frontend_ws: 341 payload: Dict[str, Any] = { 342 "type": "update", 343 "board": self.board, 344 "scores": self.scores, 345 "current_turn": self.current_turn, 346 "p1_connected": self.agent1_ws is not None, 347 "p2_connected": self.agent2_ws is not None, 348 "game_over": game_over_msg, 349 } 350 try: 351 await self.frontend_ws.send(json.dumps(payload)) 352 except Exception: 353 self.frontend_ws = None 354 logging.info("Frontend disconnected.")
Connect Four Server that manages game state, players, and communication.
19 def __init__(self) -> None: 20 """ 21 Initializes the Connect4Server with default game settings and state. 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.rows: int = 6 28 self.cols: int = 7 29 self.board: List[List[int]] = [ 30 [0 for _ in range(self.cols)] for _ in range(self.rows) 31 ] 32 33 self.first_player_this_round: int = 1 34 self.current_turn: int = 1 35 self.running: bool = False 36 37 self.scores: Dict[int, int] = {1: 0, 2: 0}
Initializes the Connect4Server with default game settings and state.
39 async def start(self, host: str = "0.0.0.0", port: int = 8765) -> None: 40 """ 41 Starts the Connect Four WebSocket server. 42 43 Args: 44 host: The host address to bind the server to. 45 port: The port number to listen on. 46 """ 47 logging.info(f"Connect Four Server started on ws://{host}:{port}") 48 async with serve(self.handle_client, host, port): 49 await asyncio.Future()
Starts the Connect Four WebSocket server.
Args: host: The host address to bind the server to. port: The port number to listen on.
51 async def handle_client( 52 self, websocket: ServerConnection 53 ) -> None: 54 """ 55 Handles incoming WebSocket connections and routes them based on client type. 56 57 Args: 58 websocket: The WebSocket connection object. 59 """ 60 client_type: str = "Unknown" 61 try: 62 init_msg = await websocket.recv() 63 if isinstance(init_msg, bytes): 64 init_msg = init_msg.decode("utf-8") 65 data: Dict[str, Any] = json.loads(init_msg) 66 client_type = data.get("client", "Unknown") 67 68 if client_type == "frontend": 69 logging.info("Frontend connected.") 70 self.frontend_ws = websocket 71 await self.update_frontend() 72 await self.frontend_loop(websocket) 73 elif client_type == "agent": 74 if not self.agent1_ws: 75 self.agent1_ws = websocket 76 logging.info("Player 1 connected.") 77 try: 78 await websocket.send( 79 json.dumps({"type": "setup", "player_id": 1}) 80 ) 81 except Exception: 82 self.agent1_ws = None 83 return 84 await self.check_start_conditions() 85 await self.agent_loop(websocket, 1) 86 elif not self.agent2_ws: 87 self.agent2_ws = websocket 88 logging.info("Player 2 connected.") 89 try: 90 await websocket.send( 91 json.dumps({"type": "setup", "player_id": 2}) 92 ) 93 except Exception: 94 self.agent2_ws = None 95 return 96 await self.check_start_conditions() 97 await self.agent_loop(websocket, 2) 98 else: 99 logging.warning("A 3rd agent tried to connect. Rejected.") 100 await websocket.close() 101 except Exception as e: 102 logging.error(f"Error handling client {client_type}: {e}") 103 finally: 104 if websocket == self.frontend_ws: 105 self.frontend_ws = None 106 elif websocket == self.agent1_ws: 107 self.agent1_ws = None 108 self.running = False 109 logging.info("Player 1 disconnected. Pausing game.") 110 await self.update_frontend() 111 elif websocket == self.agent2_ws: 112 self.agent2_ws = None 113 self.running = False 114 logging.info("Player 2 disconnected. Pausing game.") 115 await self.update_frontend()
Handles incoming WebSocket connections and routes them based on client type.
Args: websocket: The WebSocket connection object.
117 async def frontend_loop( 118 self, websocket: ServerConnection 119 ) -> None: 120 """ 121 Keeps the frontend connection alive. 122 123 Args: 124 websocket: The frontend WebSocket connection object. 125 """ 126 async for _ in websocket: 127 pass # Frontend is view-only for now
Keeps the frontend connection alive.
Args: websocket: The frontend WebSocket connection object.
129 async def agent_loop( 130 self, websocket: ServerConnection, player_id: int 131 ) -> None: 132 """ 133 Main loop for communicating with game agents. 134 135 Args: 136 websocket: The agent WebSocket connection object. 137 player_id: The ID of the player (1 or 2). 138 """ 139 async for message in websocket: 140 if not self.running or self.current_turn != player_id: 141 continue 142 try: 143 if isinstance(message, bytes): 144 message = message.decode("utf-8") 145 data: Dict[str, Any] = json.loads(message) 146 if data.get("action") == "move" and isinstance(data.get("column"), int): 147 col: int = data["column"] 148 row: Optional[int] = self.process_move(player_id, col) 149 if row is not None: 150 await self.update_frontend() 151 if await self.check_game_over(row, col): 152 continue # Round ended, don't swap turns here 153 154 self.current_turn = 3 - self.current_turn 155 await self.broadcast_state() 156 except Exception as e: 157 logging.error(f"Error processing move for Player {player_id}: {e}")
Main loop for communicating with game agents.
Args: websocket: The agent WebSocket connection object. player_id: The ID of the player (1 or 2).
159 async def check_start_conditions(self) -> None: 160 """ 161 Checks if both agents are connected and starts the game if they are. 162 """ 163 if self.agent1_ws and self.agent2_ws and not self.running: 164 logging.info(f"Both agents connected. Starting round. Player {self.first_player_this_round} goes first.") 165 self.running = True 166 self.board = [[0 for _ in range(self.cols)] for _ in range(self.rows)] 167 self.current_turn = self.first_player_this_round 168 await self.update_frontend() 169 await self.broadcast_state()
Checks if both agents are connected and starts the game if they are.
171 def get_valid_actions(self) -> List[int]: 172 """ 173 Returns a list of column indices [0-6] that are not full. 174 175 Returns: 176 List of valid column indices. 177 """ 178 return [c for c in range(self.cols) if self.board[0][c] == 0]
Returns a list of column indices [0-6] that are not full.
Returns: List of valid column indices.
180 def process_move(self, player_id: int, col: int) -> Optional[int]: 181 """ 182 Processes a move for a player. 183 184 Args: 185 player_id: The ID of the player making the move. 186 col: The column index where the piece is dropped. 187 188 Returns: 189 The row index where the piece landed, or None if the move was invalid. 190 """ 191 if col not in self.get_valid_actions(): 192 logging.warning(f"Player {player_id} attempted invalid move in column {col}") 193 return None 194 195 # Gravity: drop the piece to the lowest available row 196 for r in range(self.rows - 1, -1, -1): 197 if self.board[r][col] == 0: 198 self.board[r][col] = player_id 199 return r 200 return None
Processes a move for a player.
Args: player_id: The ID of the player making the move. col: The column index where the piece is dropped.
Returns: The row index where the piece landed, or None if the move was invalid.
202 async def check_game_over(self, last_row: int, last_col: int) -> bool: 203 """ 204 Checks if the game has ended in a win or draw. 205 206 Args: 207 last_row: The row index of the last move. 208 last_col: The column index of the last move. 209 210 Returns: 211 True if the game is over, False otherwise. 212 """ 213 winner: int = self.check_win(last_row, last_col) 214 valid_actions: List[int] = self.get_valid_actions() 215 216 if winner: 217 logging.info(f"Player {winner} wins the round!") 218 self.scores[winner] += 1 219 await self.end_round(f"Player {winner} Wins!") 220 return True 221 elif not valid_actions: 222 logging.info("Round ended in a Draw.") 223 await self.end_round("Draw!") 224 return True 225 return False
Checks if the game has ended in a win or draw.
Args: last_row: The row index of the last move. last_col: The column index of the last move.
Returns: True if the game is over, False otherwise.
227 def check_win(self, r: int, c: int) -> int: 228 """ 229 Checks for 4 in a row intersecting the last move (r, c). 230 231 Args: 232 r: Row index of the last move. 233 c: Column index of the last move. 234 235 Returns: 236 The ID of the winning player, or 0 if no win was found. 237 """ 238 b = self.board 239 p = b[r][c] 240 if p == 0: 241 return 0 242 243 directions = [ 244 (0, 1), # Horizontal 245 (1, 0), # Vertical 246 (1, 1), # Diagonal / 247 (1, -1), # Diagonal \ 248 ] 249 250 for dr, dc in directions: 251 count = 1 252 # Check in positive direction 253 for i in range(1, 4): 254 nr, nc = r + dr * i, c + dc * i 255 if 0 <= nr < self.rows and 0 <= nc < self.cols and b[nr][nc] == p: 256 count += 1 257 else: 258 break 259 # Check in negative direction 260 for i in range(1, 4): 261 nr, nc = r - dr * i, c - dc * i 262 if 0 <= nr < self.rows and 0 <= nc < self.cols and b[nr][nc] == p: 263 count += 1 264 else: 265 break 266 if count >= 4: 267 return p 268 return 0
Checks for 4 in a row intersecting the last move (r, c).
Args: r: Row index of the last move. c: Column index of the last move.
Returns: The ID of the winning player, or 0 if no win was found.
270 async def end_round(self, message: str) -> None: 271 """ 272 Notifies agents of the outcome, swaps the starting player, and restarts. 273 274 Args: 275 message: Game outcome message. 276 """ 277 self.running = False 278 payload: Dict[str, Any] = { 279 "type": "game_over", 280 "message": message, 281 "scores": self.scores, 282 "board": self.board, 283 } 284 await self.broadcast_to_agents(payload) 285 await self.update_frontend(game_over_msg=message) 286 287 # Pause briefly so humans can see the winning move on the UI 288 await asyncio.sleep(2.0) 289 290 # Swap who goes first and restart automatically 291 self.first_player_this_round = 3 - self.first_player_this_round 292 # Re-check connections after sleep 293 if self.agent1_ws and self.agent2_ws: 294 await self.check_start_conditions() 295 else: 296 logging.info("Round ended and an agent disconnected. Waiting for players...")
Notifies agents of the outcome, swaps the starting player, and restarts.
Args: message: Game outcome message.
298 async def broadcast_to_agents(self, payload: Dict[str, Any]) -> None: 299 """ 300 Sends a message to both connected agents. 301 302 Args: 303 payload: The message to send. 304 """ 305 msg: str = json.dumps(payload) 306 # Handle agent1_ws 307 if self.agent1_ws: 308 try: 309 await self.agent1_ws.send(msg) 310 except Exception: 311 self.agent1_ws = None 312 logging.info("Player 1 disconnected during broadcast.") 313 # Handle agent2_ws 314 if self.agent2_ws: 315 try: 316 await self.agent2_ws.send(msg) 317 except Exception: 318 self.agent2_ws = None 319 logging.info("Player 2 disconnected during broadcast.")
Sends a message to both connected agents.
Args: payload: The message to send.
321 async def broadcast_state(self) -> None: 322 """ 323 Sends the board and valid actions to BOTH players. 324 """ 325 payload: Dict[str, Any] = { 326 "type": "state", 327 "board": self.board, 328 "valid_actions": self.get_valid_actions(), 329 "current_turn": self.current_turn, 330 } 331 await self.broadcast_to_agents(payload)
Sends the board and valid actions to BOTH players.
333 async def update_frontend(self, game_over_msg: Optional[str] = None) -> None: 334 """ 335 Sends current game state to the frontend. 336 337 Args: 338 game_over_msg: Optional game over message to display on the frontend. 339 """ 340 if self.frontend_ws: 341 payload: Dict[str, Any] = { 342 "type": "update", 343 "board": self.board, 344 "scores": self.scores, 345 "current_turn": self.current_turn, 346 "p1_connected": self.agent1_ws is not None, 347 "p2_connected": self.agent2_ws is not None, 348 "game_over": game_over_msg, 349 } 350 try: 351 await self.frontend_ws.send(json.dumps(payload)) 352 except Exception: 353 self.frontend_ws = None 354 logging.info("Frontend disconnected.")
Sends current game state to the frontend.
Args: game_over_msg: Optional game over message to display on the frontend.