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())
class Connect4Server:
 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.

Connect4Server()
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.

frontend_ws: Optional[websockets.asyncio.server.ServerConnection]
agent1_ws: Optional[websockets.asyncio.server.ServerConnection]
agent2_ws: Optional[websockets.asyncio.server.ServerConnection]
rows: int
cols: int
board: List[List[int]]
first_player_this_round: int
current_turn: int
running: bool
scores: Dict[int, int]
async def start(self, host: str = '0.0.0.0', port: int = 8765) -> None:
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.

async def handle_client(self, websocket: websockets.asyncio.server.ServerConnection) -> None:
 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.

async def frontend_loop(self, websocket: websockets.asyncio.server.ServerConnection) -> None:
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.

async def agent_loop( self, websocket: websockets.asyncio.server.ServerConnection, player_id: int) -> None:
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).

async def check_start_conditions(self) -> None:
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.

def get_valid_actions(self) -> List[int]:
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.

def process_move(self, player_id: int, col: int) -> Optional[int]:
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.

async def check_game_over(self, last_row: int, last_col: int) -> bool:
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.

def check_win(self, r: int, c: int) -> int:
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.

async def end_round(self, message: str) -> None:
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.

async def broadcast_to_agents(self, payload: Dict[str, Any]) -> None:
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.

async def broadcast_state(self) -> None:
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.

async def update_frontend(self, game_over_msg: Optional[str] = None) -> None:
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.