backend.server

  1import asyncio
  2import json
  3import logging
  4from typing import Dict, List, Optional, Tuple, Any
  5
  6from websockets.server import serve
  7
  8logging.basicConfig(
  9    level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
 10)
 11
 12
 13class OthelloServer:
 14    r"""
 15    Othello game server implementation using WebSockets.
 16
 17    Manages the game state, connects agents and frontend, and broadcasts updates.
 18    The game board is an $N \times N$ grid, where $N = 8$.
 19    The objective is to maximize the number of discs of the player's color $P$:
 20    $\max \sum_{i=1}^{N} \sum_{j=1}^{N} [B_{i,j} = P]$, where $B_{i,j}$ is the board state at $(i,j)$.
 21    """
 22
 23    def __init__(self) -> None:
 24        """Initializes the OthelloServer with default values."""
 25        self.frontend_ws: Optional[Any] = None
 26        self.agent1_ws: Optional[Any] = None
 27        self.agent2_ws: Optional[Any] = None
 28
 29        self.size: int = 8
 30        self.board: List[List[int]] = [[0] * self.size for _ in range(self.size)]
 31        self.directions: List[Tuple[int, int]] = [
 32            (-1, -1),
 33            (-1, 0),
 34            (-1, 1),
 35            (0, -1),
 36            (0, 1),
 37            (1, -1),
 38            (1, 0),
 39            (1, 1),
 40        ]
 41
 42        self.first_player_this_round: int = 1
 43        self.current_turn: int = 1
 44        self.running: bool = False
 45        self.match_scores: Dict[int, int] = {1: 0, 2: 0}  # Tracks rounds won
 46        self.broadcast_lock = asyncio.Lock()
 47
 48    async def start(self, host: str = "0.0.0.0", port: int = 8765) -> None:
 49        """
 50        Starts the Othello server.
 51
 52        Args:
 53            host (str): The host to bind to. Defaults to "0.0.0.0".
 54            port (int): The port to listen on. Defaults to 8765.
 55        """
 56        logging.info(f"Othello Server started on ws://{host}:{port}")
 57        async with serve(self.handle_client, host, port):
 58            await asyncio.Future()
 59
 60    async def handle_client(self, websocket: Any) -> None:
 61        """
 62        Handles incoming WebSocket connections.
 63
 64        Args:
 65            websocket: The incoming WebSocket connection.
 66        """
 67        logging.info(f"New connection attempt from {websocket.remote_address}")
 68        client_type: str = "Unknown"
 69        try:
 70            init_msg = await websocket.recv()
 71            logging.info(f"Received initial message: {init_msg}")
 72            data: Dict[str, Any] = json.loads(init_msg)
 73            client_type = data.get("client", "Unknown")
 74
 75            if client_type == "frontend":
 76                logging.info("Frontend connected.")
 77                self.frontend_ws = websocket
 78                await self.update_frontend()
 79                await self.frontend_loop(websocket)
 80            elif client_type == "agent":
 81                if not self.agent1_ws:
 82                    self.agent1_ws = websocket
 83                    logging.info("Player 1 (Black) connected.")
 84                    await websocket.send(json.dumps({"type": "setup", "player_id": 1}))
 85                    await self.check_start_conditions()
 86                    await self.agent_loop(websocket, 1)
 87                elif not self.agent2_ws:
 88                    self.agent2_ws = websocket
 89                    logging.info("Player 2 (White) connected.")
 90                    await websocket.send(json.dumps({"type": "setup", "player_id": 2}))
 91                    await self.check_start_conditions()
 92                    await self.agent_loop(websocket, 2)
 93                else:
 94                    logging.warning("Extra agent attempted to connect. Closing connection.")
 95                    await websocket.close()
 96            else:
 97                logging.warning(f"Unknown client type: {client_type}")
 98                await websocket.close()
 99        except Exception as e:
100            logging.error(f"Error in handle_client ({client_type}): {e}")
101        finally:
102            logging.info(f"Connection closed for {client_type}")
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. Game stopped.")
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. Game stopped.")
114                await self.update_frontend()
115
116    async def frontend_loop(self, websocket: Any) -> None:
117        """
118        Main loop for frontend WebSocket connection.
119
120        Args:
121            websocket: The frontend WebSocket connection.
122        """
123        async for _ in websocket:
124            pass
125
126    async def agent_loop(self, websocket: Any, player_id: int) -> None:
127        """
128        Main loop for agent WebSocket connection.
129
130        Args:
131            websocket: The agent WebSocket connection.
132            player_id (int): The ID of the player associated with this agent.
133        """
134        async for message in websocket:
135            logging.debug(f"Received message from Player {player_id}: {message}")
136            if not self.running or self.current_turn != player_id:
137                continue
138            try:
139                data: Dict[str, Any] = json.loads(message)
140                if data.get("action") == "move":
141                    x, y = data.get("x"), data.get("y")
142                    logging.info(f"Player {player_id} moves at ({x}, {y})")
143                    if x is not None and y is not None:
144                        if self.process_move(player_id, x, y):
145                            await self.advance_turn()
146                        else:
147                            logging.warning(f"Invalid move from Player {player_id}: ({x}, {y})")
148            except Exception as e:
149                logging.error(f"Error processing move: {e}")
150
151    async def check_start_conditions(self) -> None:
152        """Checks if all conditions are met to start a new game round."""
153        if self.agent1_ws and self.agent2_ws and not self.running:
154            logging.info("Both agents connected. Starting game.")
155            self.running = True
156            self.board = [[0] * self.size for _ in range(self.size)]
157            # Othello starting position
158            self.board[3][3], self.board[4][4] = 2, 2  # White
159            self.board[3][4], self.board[4][3] = 1, 1  # Black
160
161            self.current_turn = self.first_player_this_round
162            await self.update_frontend()
163            await self.broadcast_state()
164
165    def get_flips(self, player_id: int, x: int, y: int) -> List[Tuple[int, int]]:
166        """
167        Calculates which pieces would be flipped if player_id moves at (x,y).
168
169        Args:
170            player_id (int): The ID of the player making the move.
171            x (int): The x-coordinate of the move.
172            y (int): The y-coordinate of the move.
173
174        Returns:
175            List[Tuple[int, int]]: A list of coordinates to be flipped.
176        """
177        if x < 0 or x >= self.size or y < 0 or y >= self.size:
178            return []
179
180        if self.board[y][x] != 0:
181            return []
182
183        opponent: int = 3 - player_id
184        flips: List[Tuple[int, int]] = []
185
186        for dx, dy in self.directions:
187            nx, ny = x + dx, y + dy
188            temp_flips: List[Tuple[int, int]] = []
189
190            while (
191                0 <= nx < self.size
192                and 0 <= ny < self.size
193                and self.board[ny][nx] == opponent
194            ):
195                temp_flips.append((nx, ny))
196                nx += dx
197                ny += dy
198
199            if (
200                0 <= nx < self.size
201                and 0 <= ny < self.size
202                and self.board[ny][nx] == player_id
203            ):
204                flips.extend(temp_flips)
205
206        return flips
207
208    def get_valid_actions(self, player_id: int) -> List[List[int]]:
209        """
210        Returns a list of valid moves for a given player.
211
212        Args:
213            player_id (int): The ID of the player.
214
215        Returns:
216            List[List[int]]: A list of valid move coordinates [x, y].
217        """
218        actions: List[List[int]] = []
219        for y in range(self.size):
220            for x in range(self.size):
221                if len(self.get_flips(player_id, x, y)) > 0:
222                    actions.append([x, y])
223        return actions
224
225    def process_move(self, player_id: int, x: int, y: int) -> bool:
226        """
227        Processes a move for a player.
228
229        Args:
230            player_id (int): The ID of the player making the move.
231            x (int): The x-coordinate of the move.
232            y (int): The y-coordinate of the move.
233
234        Returns:
235            bool: True if the move was successful, False otherwise.
236        """
237        flips: List[Tuple[int, int]] = self.get_flips(player_id, x, y)
238        if not flips:
239            return False
240
241        self.board[y][x] = player_id
242        for fx, fy in flips:
243            self.board[fy][fx] = player_id
244        return True
245
246    async def advance_turn(self) -> None:
247        """Advances the turn, handling Othello's skip rules."""
248        await self.update_frontend()
249
250        next_player: int = 3 - self.current_turn
251        valid_next: List[List[int]] = self.get_valid_actions(next_player)
252
253        if valid_next:
254            self.current_turn = next_player
255            logging.info(f"Turn advanced to Player {self.current_turn}")
256        else:
257            # Next player has no moves. Do we have moves?
258            valid_current: List[List[int]] = self.get_valid_actions(self.current_turn)
259            if not valid_current:
260                logging.info("No more valid moves for either player. Game over.")
261                await self.check_game_over()
262                return
263            else:
264                logging.info(f"Player {next_player} has no moves. Turn remains with Player {self.current_turn}")
265
266        if self.running:
267            await self.broadcast_state()
268
269    def count_discs(self) -> Tuple[int, int]:
270        """
271        Counts the number of discs for each player.
272
273        Returns:
274            Tuple[int, int]: A tuple containing (player1_count, player2_count).
275        """
276        p1, p2 = 0, 0
277        for row in self.board:
278            for cell in row:
279                if cell == 1:
280                    p1 += 1
281                elif cell == 2:
282                    p2 += 1
283        return p1, p2
284
285    async def check_game_over(self) -> None:
286        """Checks if the game is over and determines the winner."""
287        p1, p2 = self.count_discs()
288        winner: int = 1 if p1 > p2 else (2 if p2 > p1 else 0)
289
290        if winner:
291            self.match_scores[winner] += 1
292
293        msg: str = (
294            f"Player {winner} Wins ({p1}-{p2})!" if winner else f"Draw ({p1}-{p2})!"
295        )
296        logging.info(f"Game Over: {msg}")
297        await self.end_round(msg)
298
299    async def end_round(self, message: str) -> None:
300        """
301        Ends the current round and prepares for the next.
302
303        Args:
304            message (str): The message to broadcast (winner/draw).
305        """
306        self.running = False
307        payload: Dict[str, Any] = {"type": "game_over", "message": message}
308        if self.agent1_ws:
309            await self.agent1_ws.send(json.dumps(payload))
310        if self.agent2_ws:
311            await self.agent2_ws.send(json.dumps(payload))
312        await self.update_frontend()
313
314        logging.info("Round ended. Preparing for next round in 3 seconds...")
315        await asyncio.sleep(3.0)
316        self.first_player_this_round = 3 - self.first_player_this_round
317        await self.check_start_conditions()
318
319    async def broadcast_state(self) -> None:
320        """Broadcasts the current game state to all agents."""
321        async with self.broadcast_lock:
322            valid_actions: List[List[int]] = self.get_valid_actions(self.current_turn)
323            logging.info(f"Broadcasting state for Player {self.current_turn}. Valid moves: {valid_actions}")
324            payload: Dict[str, Any] = {
325                "type": "state",
326                "board": self.board,
327                "current_turn": self.current_turn,
328                "valid_actions": valid_actions,
329            }
330            msg: str = json.dumps(payload)
331            if self.agent1_ws:
332                await self.agent1_ws.send(msg)
333            if self.agent2_ws:
334                await self.agent2_ws.send(msg)
335
336    async def update_frontend(self) -> None:
337        """Sends an update message to the frontend."""
338        if self.frontend_ws:
339            p1, p2 = self.count_discs()
340            try:
341                await self.frontend_ws.send(
342                    json.dumps(
343                        {
344                            "type": "update",
345                            "board": self.board,
346                            "current_turn": self.current_turn,
347                            "valid_actions": self.get_valid_actions(self.current_turn)
348                            if self.running
349                            else [],
350                            "disc_counts": {1: p1, 2: p2},
351                            "match_scores": self.match_scores,
352                            "p1_connected": self.agent1_ws is not None,
353                            "p2_connected": self.agent2_ws is not None,
354                        }
355                    )
356                )
357            except Exception as e:
358                logging.error(f"Error updating frontend: {e}")
359                self.frontend_ws = None
360
361
362if __name__ == "__main__":
363    server = OthelloServer()
364    asyncio.run(server.start())
class OthelloServer:
 14class OthelloServer:
 15    r"""
 16    Othello game server implementation using WebSockets.
 17
 18    Manages the game state, connects agents and frontend, and broadcasts updates.
 19    The game board is an $N \times N$ grid, where $N = 8$.
 20    The objective is to maximize the number of discs of the player's color $P$:
 21    $\max \sum_{i=1}^{N} \sum_{j=1}^{N} [B_{i,j} = P]$, where $B_{i,j}$ is the board state at $(i,j)$.
 22    """
 23
 24    def __init__(self) -> None:
 25        """Initializes the OthelloServer with default values."""
 26        self.frontend_ws: Optional[Any] = None
 27        self.agent1_ws: Optional[Any] = None
 28        self.agent2_ws: Optional[Any] = None
 29
 30        self.size: int = 8
 31        self.board: List[List[int]] = [[0] * self.size for _ in range(self.size)]
 32        self.directions: List[Tuple[int, int]] = [
 33            (-1, -1),
 34            (-1, 0),
 35            (-1, 1),
 36            (0, -1),
 37            (0, 1),
 38            (1, -1),
 39            (1, 0),
 40            (1, 1),
 41        ]
 42
 43        self.first_player_this_round: int = 1
 44        self.current_turn: int = 1
 45        self.running: bool = False
 46        self.match_scores: Dict[int, int] = {1: 0, 2: 0}  # Tracks rounds won
 47        self.broadcast_lock = asyncio.Lock()
 48
 49    async def start(self, host: str = "0.0.0.0", port: int = 8765) -> None:
 50        """
 51        Starts the Othello server.
 52
 53        Args:
 54            host (str): The host to bind to. Defaults to "0.0.0.0".
 55            port (int): The port to listen on. Defaults to 8765.
 56        """
 57        logging.info(f"Othello 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: Any) -> None:
 62        """
 63        Handles incoming WebSocket connections.
 64
 65        Args:
 66            websocket: The incoming WebSocket connection.
 67        """
 68        logging.info(f"New connection attempt from {websocket.remote_address}")
 69        client_type: str = "Unknown"
 70        try:
 71            init_msg = await websocket.recv()
 72            logging.info(f"Received initial message: {init_msg}")
 73            data: Dict[str, Any] = json.loads(init_msg)
 74            client_type = data.get("client", "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 (Black) connected.")
 85                    await websocket.send(json.dumps({"type": "setup", "player_id": 1}))
 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 (White) connected.")
 91                    await websocket.send(json.dumps({"type": "setup", "player_id": 2}))
 92                    await self.check_start_conditions()
 93                    await self.agent_loop(websocket, 2)
 94                else:
 95                    logging.warning("Extra agent attempted to connect. Closing connection.")
 96                    await websocket.close()
 97            else:
 98                logging.warning(f"Unknown client type: {client_type}")
 99                await websocket.close()
100        except Exception as e:
101            logging.error(f"Error in handle_client ({client_type}): {e}")
102        finally:
103            logging.info(f"Connection closed for {client_type}")
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. Game stopped.")
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. Game stopped.")
115                await self.update_frontend()
116
117    async def frontend_loop(self, websocket: Any) -> None:
118        """
119        Main loop for frontend WebSocket connection.
120
121        Args:
122            websocket: The frontend WebSocket connection.
123        """
124        async for _ in websocket:
125            pass
126
127    async def agent_loop(self, websocket: Any, player_id: int) -> None:
128        """
129        Main loop for agent WebSocket connection.
130
131        Args:
132            websocket: The agent WebSocket connection.
133            player_id (int): The ID of the player associated with this agent.
134        """
135        async for message in websocket:
136            logging.debug(f"Received message from Player {player_id}: {message}")
137            if not self.running or self.current_turn != player_id:
138                continue
139            try:
140                data: Dict[str, Any] = json.loads(message)
141                if data.get("action") == "move":
142                    x, y = data.get("x"), data.get("y")
143                    logging.info(f"Player {player_id} moves at ({x}, {y})")
144                    if x is not None and y is not None:
145                        if self.process_move(player_id, x, y):
146                            await self.advance_turn()
147                        else:
148                            logging.warning(f"Invalid move from Player {player_id}: ({x}, {y})")
149            except Exception as e:
150                logging.error(f"Error processing move: {e}")
151
152    async def check_start_conditions(self) -> None:
153        """Checks if all conditions are met to start a new game round."""
154        if self.agent1_ws and self.agent2_ws and not self.running:
155            logging.info("Both agents connected. Starting game.")
156            self.running = True
157            self.board = [[0] * self.size for _ in range(self.size)]
158            # Othello starting position
159            self.board[3][3], self.board[4][4] = 2, 2  # White
160            self.board[3][4], self.board[4][3] = 1, 1  # Black
161
162            self.current_turn = self.first_player_this_round
163            await self.update_frontend()
164            await self.broadcast_state()
165
166    def get_flips(self, player_id: int, x: int, y: int) -> List[Tuple[int, int]]:
167        """
168        Calculates which pieces would be flipped if player_id moves at (x,y).
169
170        Args:
171            player_id (int): The ID of the player making the move.
172            x (int): The x-coordinate of the move.
173            y (int): The y-coordinate of the move.
174
175        Returns:
176            List[Tuple[int, int]]: A list of coordinates to be flipped.
177        """
178        if x < 0 or x >= self.size or y < 0 or y >= self.size:
179            return []
180
181        if self.board[y][x] != 0:
182            return []
183
184        opponent: int = 3 - player_id
185        flips: List[Tuple[int, int]] = []
186
187        for dx, dy in self.directions:
188            nx, ny = x + dx, y + dy
189            temp_flips: List[Tuple[int, int]] = []
190
191            while (
192                0 <= nx < self.size
193                and 0 <= ny < self.size
194                and self.board[ny][nx] == opponent
195            ):
196                temp_flips.append((nx, ny))
197                nx += dx
198                ny += dy
199
200            if (
201                0 <= nx < self.size
202                and 0 <= ny < self.size
203                and self.board[ny][nx] == player_id
204            ):
205                flips.extend(temp_flips)
206
207        return flips
208
209    def get_valid_actions(self, player_id: int) -> List[List[int]]:
210        """
211        Returns a list of valid moves for a given player.
212
213        Args:
214            player_id (int): The ID of the player.
215
216        Returns:
217            List[List[int]]: A list of valid move coordinates [x, y].
218        """
219        actions: List[List[int]] = []
220        for y in range(self.size):
221            for x in range(self.size):
222                if len(self.get_flips(player_id, x, y)) > 0:
223                    actions.append([x, y])
224        return actions
225
226    def process_move(self, player_id: int, x: int, y: int) -> bool:
227        """
228        Processes a move for a player.
229
230        Args:
231            player_id (int): The ID of the player making the move.
232            x (int): The x-coordinate of the move.
233            y (int): The y-coordinate of the move.
234
235        Returns:
236            bool: True if the move was successful, False otherwise.
237        """
238        flips: List[Tuple[int, int]] = self.get_flips(player_id, x, y)
239        if not flips:
240            return False
241
242        self.board[y][x] = player_id
243        for fx, fy in flips:
244            self.board[fy][fx] = player_id
245        return True
246
247    async def advance_turn(self) -> None:
248        """Advances the turn, handling Othello's skip rules."""
249        await self.update_frontend()
250
251        next_player: int = 3 - self.current_turn
252        valid_next: List[List[int]] = self.get_valid_actions(next_player)
253
254        if valid_next:
255            self.current_turn = next_player
256            logging.info(f"Turn advanced to Player {self.current_turn}")
257        else:
258            # Next player has no moves. Do we have moves?
259            valid_current: List[List[int]] = self.get_valid_actions(self.current_turn)
260            if not valid_current:
261                logging.info("No more valid moves for either player. Game over.")
262                await self.check_game_over()
263                return
264            else:
265                logging.info(f"Player {next_player} has no moves. Turn remains with Player {self.current_turn}")
266
267        if self.running:
268            await self.broadcast_state()
269
270    def count_discs(self) -> Tuple[int, int]:
271        """
272        Counts the number of discs for each player.
273
274        Returns:
275            Tuple[int, int]: A tuple containing (player1_count, player2_count).
276        """
277        p1, p2 = 0, 0
278        for row in self.board:
279            for cell in row:
280                if cell == 1:
281                    p1 += 1
282                elif cell == 2:
283                    p2 += 1
284        return p1, p2
285
286    async def check_game_over(self) -> None:
287        """Checks if the game is over and determines the winner."""
288        p1, p2 = self.count_discs()
289        winner: int = 1 if p1 > p2 else (2 if p2 > p1 else 0)
290
291        if winner:
292            self.match_scores[winner] += 1
293
294        msg: str = (
295            f"Player {winner} Wins ({p1}-{p2})!" if winner else f"Draw ({p1}-{p2})!"
296        )
297        logging.info(f"Game Over: {msg}")
298        await self.end_round(msg)
299
300    async def end_round(self, message: str) -> None:
301        """
302        Ends the current round and prepares for the next.
303
304        Args:
305            message (str): The message to broadcast (winner/draw).
306        """
307        self.running = False
308        payload: Dict[str, Any] = {"type": "game_over", "message": message}
309        if self.agent1_ws:
310            await self.agent1_ws.send(json.dumps(payload))
311        if self.agent2_ws:
312            await self.agent2_ws.send(json.dumps(payload))
313        await self.update_frontend()
314
315        logging.info("Round ended. Preparing for next round in 3 seconds...")
316        await asyncio.sleep(3.0)
317        self.first_player_this_round = 3 - self.first_player_this_round
318        await self.check_start_conditions()
319
320    async def broadcast_state(self) -> None:
321        """Broadcasts the current game state to all agents."""
322        async with self.broadcast_lock:
323            valid_actions: List[List[int]] = self.get_valid_actions(self.current_turn)
324            logging.info(f"Broadcasting state for Player {self.current_turn}. Valid moves: {valid_actions}")
325            payload: Dict[str, Any] = {
326                "type": "state",
327                "board": self.board,
328                "current_turn": self.current_turn,
329                "valid_actions": valid_actions,
330            }
331            msg: str = json.dumps(payload)
332            if self.agent1_ws:
333                await self.agent1_ws.send(msg)
334            if self.agent2_ws:
335                await self.agent2_ws.send(msg)
336
337    async def update_frontend(self) -> None:
338        """Sends an update message to the frontend."""
339        if self.frontend_ws:
340            p1, p2 = self.count_discs()
341            try:
342                await self.frontend_ws.send(
343                    json.dumps(
344                        {
345                            "type": "update",
346                            "board": self.board,
347                            "current_turn": self.current_turn,
348                            "valid_actions": self.get_valid_actions(self.current_turn)
349                            if self.running
350                            else [],
351                            "disc_counts": {1: p1, 2: p2},
352                            "match_scores": self.match_scores,
353                            "p1_connected": self.agent1_ws is not None,
354                            "p2_connected": self.agent2_ws is not None,
355                        }
356                    )
357                )
358            except Exception as e:
359                logging.error(f"Error updating frontend: {e}")
360                self.frontend_ws = None

Othello game server implementation using WebSockets.

Manages the game state, connects agents and frontend, and broadcasts updates. The game board is an $N \times N$ grid, where $N = 8$. The objective is to maximize the number of discs of the player's color $P$: $\max \sum_{i=1}^{N} \sum_{j=1}^{N} [B_{i,j} = P]$, where $B_{i,j}$ is the board state at $(i,j)$.

OthelloServer()
24    def __init__(self) -> None:
25        """Initializes the OthelloServer with default values."""
26        self.frontend_ws: Optional[Any] = None
27        self.agent1_ws: Optional[Any] = None
28        self.agent2_ws: Optional[Any] = None
29
30        self.size: int = 8
31        self.board: List[List[int]] = [[0] * self.size for _ in range(self.size)]
32        self.directions: List[Tuple[int, int]] = [
33            (-1, -1),
34            (-1, 0),
35            (-1, 1),
36            (0, -1),
37            (0, 1),
38            (1, -1),
39            (1, 0),
40            (1, 1),
41        ]
42
43        self.first_player_this_round: int = 1
44        self.current_turn: int = 1
45        self.running: bool = False
46        self.match_scores: Dict[int, int] = {1: 0, 2: 0}  # Tracks rounds won
47        self.broadcast_lock = asyncio.Lock()

Initializes the OthelloServer with default values.

frontend_ws: Optional[Any]
agent1_ws: Optional[Any]
agent2_ws: Optional[Any]
size: int
board: List[List[int]]
directions: List[Tuple[int, int]]
first_player_this_round: int
current_turn: int
running: bool
match_scores: Dict[int, int]
broadcast_lock
async def start(self, host: str = '0.0.0.0', port: int = 8765) -> None:
49    async def start(self, host: str = "0.0.0.0", port: int = 8765) -> None:
50        """
51        Starts the Othello server.
52
53        Args:
54            host (str): The host to bind to. Defaults to "0.0.0.0".
55            port (int): The port to listen on. Defaults to 8765.
56        """
57        logging.info(f"Othello Server started on ws://{host}:{port}")
58        async with serve(self.handle_client, host, port):
59            await asyncio.Future()

Starts the Othello server.

Args: host (str): The host to bind to. Defaults to "0.0.0.0". port (int): The port to listen on. Defaults to 8765.

async def handle_client(self, websocket: Any) -> None:
 61    async def handle_client(self, websocket: Any) -> None:
 62        """
 63        Handles incoming WebSocket connections.
 64
 65        Args:
 66            websocket: The incoming WebSocket connection.
 67        """
 68        logging.info(f"New connection attempt from {websocket.remote_address}")
 69        client_type: str = "Unknown"
 70        try:
 71            init_msg = await websocket.recv()
 72            logging.info(f"Received initial message: {init_msg}")
 73            data: Dict[str, Any] = json.loads(init_msg)
 74            client_type = data.get("client", "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 (Black) connected.")
 85                    await websocket.send(json.dumps({"type": "setup", "player_id": 1}))
 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 (White) connected.")
 91                    await websocket.send(json.dumps({"type": "setup", "player_id": 2}))
 92                    await self.check_start_conditions()
 93                    await self.agent_loop(websocket, 2)
 94                else:
 95                    logging.warning("Extra agent attempted to connect. Closing connection.")
 96                    await websocket.close()
 97            else:
 98                logging.warning(f"Unknown client type: {client_type}")
 99                await websocket.close()
100        except Exception as e:
101            logging.error(f"Error in handle_client ({client_type}): {e}")
102        finally:
103            logging.info(f"Connection closed for {client_type}")
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. Game stopped.")
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. Game stopped.")
115                await self.update_frontend()

Handles incoming WebSocket connections.

Args: websocket: The incoming WebSocket connection.

async def frontend_loop(self, websocket: Any) -> None:
117    async def frontend_loop(self, websocket: Any) -> None:
118        """
119        Main loop for frontend WebSocket connection.
120
121        Args:
122            websocket: The frontend WebSocket connection.
123        """
124        async for _ in websocket:
125            pass

Main loop for frontend WebSocket connection.

Args: websocket: The frontend WebSocket connection.

async def agent_loop(self, websocket: Any, player_id: int) -> None:
127    async def agent_loop(self, websocket: Any, player_id: int) -> None:
128        """
129        Main loop for agent WebSocket connection.
130
131        Args:
132            websocket: The agent WebSocket connection.
133            player_id (int): The ID of the player associated with this agent.
134        """
135        async for message in websocket:
136            logging.debug(f"Received message from Player {player_id}: {message}")
137            if not self.running or self.current_turn != player_id:
138                continue
139            try:
140                data: Dict[str, Any] = json.loads(message)
141                if data.get("action") == "move":
142                    x, y = data.get("x"), data.get("y")
143                    logging.info(f"Player {player_id} moves at ({x}, {y})")
144                    if x is not None and y is not None:
145                        if self.process_move(player_id, x, y):
146                            await self.advance_turn()
147                        else:
148                            logging.warning(f"Invalid move from Player {player_id}: ({x}, {y})")
149            except Exception as e:
150                logging.error(f"Error processing move: {e}")

Main loop for agent WebSocket connection.

Args: websocket: The agent WebSocket connection. player_id (int): The ID of the player associated with this agent.

async def check_start_conditions(self) -> None:
152    async def check_start_conditions(self) -> None:
153        """Checks if all conditions are met to start a new game round."""
154        if self.agent1_ws and self.agent2_ws and not self.running:
155            logging.info("Both agents connected. Starting game.")
156            self.running = True
157            self.board = [[0] * self.size for _ in range(self.size)]
158            # Othello starting position
159            self.board[3][3], self.board[4][4] = 2, 2  # White
160            self.board[3][4], self.board[4][3] = 1, 1  # Black
161
162            self.current_turn = self.first_player_this_round
163            await self.update_frontend()
164            await self.broadcast_state()

Checks if all conditions are met to start a new game round.

def get_flips(self, player_id: int, x: int, y: int) -> List[Tuple[int, int]]:
166    def get_flips(self, player_id: int, x: int, y: int) -> List[Tuple[int, int]]:
167        """
168        Calculates which pieces would be flipped if player_id moves at (x,y).
169
170        Args:
171            player_id (int): The ID of the player making the move.
172            x (int): The x-coordinate of the move.
173            y (int): The y-coordinate of the move.
174
175        Returns:
176            List[Tuple[int, int]]: A list of coordinates to be flipped.
177        """
178        if x < 0 or x >= self.size or y < 0 or y >= self.size:
179            return []
180
181        if self.board[y][x] != 0:
182            return []
183
184        opponent: int = 3 - player_id
185        flips: List[Tuple[int, int]] = []
186
187        for dx, dy in self.directions:
188            nx, ny = x + dx, y + dy
189            temp_flips: List[Tuple[int, int]] = []
190
191            while (
192                0 <= nx < self.size
193                and 0 <= ny < self.size
194                and self.board[ny][nx] == opponent
195            ):
196                temp_flips.append((nx, ny))
197                nx += dx
198                ny += dy
199
200            if (
201                0 <= nx < self.size
202                and 0 <= ny < self.size
203                and self.board[ny][nx] == player_id
204            ):
205                flips.extend(temp_flips)
206
207        return flips

Calculates which pieces would be flipped if player_id moves at (x,y).

Args: player_id (int): The ID of the player making the move. x (int): The x-coordinate of the move. y (int): The y-coordinate of the move.

Returns: List[Tuple[int, int]]: A list of coordinates to be flipped.

def get_valid_actions(self, player_id: int) -> List[List[int]]:
209    def get_valid_actions(self, player_id: int) -> List[List[int]]:
210        """
211        Returns a list of valid moves for a given player.
212
213        Args:
214            player_id (int): The ID of the player.
215
216        Returns:
217            List[List[int]]: A list of valid move coordinates [x, y].
218        """
219        actions: List[List[int]] = []
220        for y in range(self.size):
221            for x in range(self.size):
222                if len(self.get_flips(player_id, x, y)) > 0:
223                    actions.append([x, y])
224        return actions

Returns a list of valid moves for a given player.

Args: player_id (int): The ID of the player.

Returns: List[List[int]]: A list of valid move coordinates [x, y].

def process_move(self, player_id: int, x: int, y: int) -> bool:
226    def process_move(self, player_id: int, x: int, y: int) -> bool:
227        """
228        Processes a move for a player.
229
230        Args:
231            player_id (int): The ID of the player making the move.
232            x (int): The x-coordinate of the move.
233            y (int): The y-coordinate of the move.
234
235        Returns:
236            bool: True if the move was successful, False otherwise.
237        """
238        flips: List[Tuple[int, int]] = self.get_flips(player_id, x, y)
239        if not flips:
240            return False
241
242        self.board[y][x] = player_id
243        for fx, fy in flips:
244            self.board[fy][fx] = player_id
245        return True

Processes a move for a player.

Args: player_id (int): The ID of the player making the move. x (int): The x-coordinate of the move. y (int): The y-coordinate of the move.

Returns: bool: True if the move was successful, False otherwise.

async def advance_turn(self) -> None:
247    async def advance_turn(self) -> None:
248        """Advances the turn, handling Othello's skip rules."""
249        await self.update_frontend()
250
251        next_player: int = 3 - self.current_turn
252        valid_next: List[List[int]] = self.get_valid_actions(next_player)
253
254        if valid_next:
255            self.current_turn = next_player
256            logging.info(f"Turn advanced to Player {self.current_turn}")
257        else:
258            # Next player has no moves. Do we have moves?
259            valid_current: List[List[int]] = self.get_valid_actions(self.current_turn)
260            if not valid_current:
261                logging.info("No more valid moves for either player. Game over.")
262                await self.check_game_over()
263                return
264            else:
265                logging.info(f"Player {next_player} has no moves. Turn remains with Player {self.current_turn}")
266
267        if self.running:
268            await self.broadcast_state()

Advances the turn, handling Othello's skip rules.

def count_discs(self) -> Tuple[int, int]:
270    def count_discs(self) -> Tuple[int, int]:
271        """
272        Counts the number of discs for each player.
273
274        Returns:
275            Tuple[int, int]: A tuple containing (player1_count, player2_count).
276        """
277        p1, p2 = 0, 0
278        for row in self.board:
279            for cell in row:
280                if cell == 1:
281                    p1 += 1
282                elif cell == 2:
283                    p2 += 1
284        return p1, p2

Counts the number of discs for each player.

Returns: Tuple[int, int]: A tuple containing (player1_count, player2_count).

async def check_game_over(self) -> None:
286    async def check_game_over(self) -> None:
287        """Checks if the game is over and determines the winner."""
288        p1, p2 = self.count_discs()
289        winner: int = 1 if p1 > p2 else (2 if p2 > p1 else 0)
290
291        if winner:
292            self.match_scores[winner] += 1
293
294        msg: str = (
295            f"Player {winner} Wins ({p1}-{p2})!" if winner else f"Draw ({p1}-{p2})!"
296        )
297        logging.info(f"Game Over: {msg}")
298        await self.end_round(msg)

Checks if the game is over and determines the winner.

async def end_round(self, message: str) -> None:
300    async def end_round(self, message: str) -> None:
301        """
302        Ends the current round and prepares for the next.
303
304        Args:
305            message (str): The message to broadcast (winner/draw).
306        """
307        self.running = False
308        payload: Dict[str, Any] = {"type": "game_over", "message": message}
309        if self.agent1_ws:
310            await self.agent1_ws.send(json.dumps(payload))
311        if self.agent2_ws:
312            await self.agent2_ws.send(json.dumps(payload))
313        await self.update_frontend()
314
315        logging.info("Round ended. Preparing for next round in 3 seconds...")
316        await asyncio.sleep(3.0)
317        self.first_player_this_round = 3 - self.first_player_this_round
318        await self.check_start_conditions()

Ends the current round and prepares for the next.

Args: message (str): The message to broadcast (winner/draw).

async def broadcast_state(self) -> None:
320    async def broadcast_state(self) -> None:
321        """Broadcasts the current game state to all agents."""
322        async with self.broadcast_lock:
323            valid_actions: List[List[int]] = self.get_valid_actions(self.current_turn)
324            logging.info(f"Broadcasting state for Player {self.current_turn}. Valid moves: {valid_actions}")
325            payload: Dict[str, Any] = {
326                "type": "state",
327                "board": self.board,
328                "current_turn": self.current_turn,
329                "valid_actions": valid_actions,
330            }
331            msg: str = json.dumps(payload)
332            if self.agent1_ws:
333                await self.agent1_ws.send(msg)
334            if self.agent2_ws:
335                await self.agent2_ws.send(msg)

Broadcasts the current game state to all agents.

async def update_frontend(self) -> None:
337    async def update_frontend(self) -> None:
338        """Sends an update message to the frontend."""
339        if self.frontend_ws:
340            p1, p2 = self.count_discs()
341            try:
342                await self.frontend_ws.send(
343                    json.dumps(
344                        {
345                            "type": "update",
346                            "board": self.board,
347                            "current_turn": self.current_turn,
348                            "valid_actions": self.get_valid_actions(self.current_turn)
349                            if self.running
350                            else [],
351                            "disc_counts": {1: p1, 2: p2},
352                            "match_scores": self.match_scores,
353                            "p1_connected": self.agent1_ws is not None,
354                            "p2_connected": self.agent2_ws is not None,
355                        }
356                    )
357                )
358            except Exception as e:
359                logging.error(f"Error updating frontend: {e}")
360                self.frontend_ws = None

Sends an update message to the frontend.