from __future__ import annotations import threading import sys from dataclasses import dataclass from typing import Optional, Dict from .net import ServerClient from .protocol import ClientMove, format_newgame, format_move_broadcast, format_gameover from quarto.shared.game import ( GameState, new_game_state, Player, InitialMove, NormalMove, apply_initial_move, validate_and_apply_normal_move, ) @dataclass class GameSession: id: int player1: ServerClient player2: ServerClient state: GameState current_player: Player lock: threading.Lock @property def player1_name(self) -> str: return self.player1.description or f"Player{self.player1.id}" @property def player2_name(self) -> str: return self.player2.description or f"Player{self.player2.id}" def send_newgame(self) -> None: msg = format_newgame(self.player1_name, self.player2_name) self.player1.send_line(msg) self.player2.send_line(msg) def client_for_player(self, player: Player) -> ServerClient: return self.player1 if player is Player.PLAYER1 else self.player2 def player_for_client(self, client: ServerClient) -> Optional[Player]: if client is self.player1: return Player.PLAYER1 if client is self.player2: return Player.PLAYER2 return None def handle_client_move(self, client: ServerClient, move_cmd: ClientMove) -> None: with self.lock: # Ignore moves once game over if self.state.game_over: return player = self.player_for_client(client) if player is None: return if player is not self.current_player: # Not this player's turn; ignore return # Initial move if move_count == 0 and square is None if self.state.move_count == 0 and move_cmd.square is None: m_value = move_cmd.m_value if not (0 <= m_value <= 15): return if m_value not in self.state.remaining_pieces: return init_move = InitialMove(chosen_piece=m_value) apply_initial_move(self.state, init_move) # LOG: initial move print( f"[SERVER] Game #{self.id} - {self.player1_name if player is Player.PLAYER1 else self.player2_name} " f"(P{'1' if player is Player.PLAYER1 else '2'}) chooses initial piece {m_value}", file=sys.stderr, ) # Broadcast MOVE~M msg = format_move_broadcast(square=None, m_value=m_value) self.player1.send_line(msg) self.player2.send_line(msg) # After initial move, it's player2's turn self.current_player = Player.PLAYER2 return # From now on, we expect a normal move if move_cmd.square is None: return square = move_cmd.square m_value = move_cmd.m_value if m_value == 16: nm = NormalMove(square=square, next_piece=None, claim_quarto=True, final_pass=False) move_desc = f"place at {square} and CLAIM QUARTO (M=16)" elif m_value == 17: nm = NormalMove(square=square, next_piece=None, claim_quarto=False, final_pass=True) move_desc = f"place at {square} and FINAL PASS / DRAW MARKER (M=17)" else: nm = NormalMove(square=square, next_piece=m_value, claim_quarto=False, final_pass=False) move_desc = f"place at {square}, give piece {m_value}" valid, winner = validate_and_apply_normal_move(self.state, nm) if not valid: # Per spec, ignore invalid moves silently print( f"[SERVER] Game #{self.id} - INVALID MOVE from " f"{self.player1_name if player is Player.PLAYER1 else self.player2_name}: " f"square={square}, m_value={m_value}", file=sys.stderr, ) return # LOG: valid move applied print( f"[SERVER] Game #{self.id} - " f"{self.player1_name if player is Player.PLAYER1 else self.player2_name} " f"(P{'1' if player is Player.PLAYER1 else '2'}) {move_desc}", file=sys.stderr, ) # Broadcast MOVE as received self.player1.send_line(format_move_broadcast(square=square, m_value=m_value)) self.player2.send_line(format_move_broadcast(square=square, m_value=m_value)) # After applying normal move, the GameState has already handled # board full -> draw, claims, etc. if self.state.game_over: if self.state.winner is None: # DRAW # If this was not an explicit 17, we may need to broadcast 17 then draw if not nm.final_pass and m_value != 17: # Convert to final-pass broadcast per spec 8: # board full & draw => broadcast M=17 immediately, then GAMEOVER self.player1.send_line(format_move_broadcast(square=square, m_value=17)) self.player2.send_line(format_move_broadcast(square=square, m_value=17)) go = format_gameover("DRAW", None) self.player1.send_line(go) self.player2.send_line(go) # LOG: draw print( f"[SERVER] Game #{self.id} ended in DRAW", file=sys.stderr, ) else: winner_name = ( self.player1_name if self.state.winner is Player.PLAYER1 else self.player2_name ) go = format_gameover("VICTORY", winner_name) self.player1.send_line(go) self.player2.send_line(go) # LOG: victory print( f"[SERVER] Game #{self.id} ended in VICTORY for {winner_name}", file=sys.stderr, ) return # Game continues: switch current_player already done in GameState self.current_player = self.state.current_player def handle_disconnect(self, client: ServerClient) -> None: with self.lock: if self.state.game_over: return # Determine remaining player if client is self.player1 and self.player2 is not None: winner_name = self.player2_name elif client is self.player2 and self.player1 is not None: winner_name = self.player1_name else: return self.state.game_over = True self.state.winner = None # gameover reason is DISCONNECT, winner is remaining msg = format_gameover("DISCONNECT", winner_name) if client is not self.player1: self.player1.send_line(msg) if client is not self.player2: self.player2.send_line(msg) # LOG: disconnect print( f"[SERVER] Game #{self.id} ended due to DISCONNECT; remaining player '{winner_name}'", file=sys.stderr, ) class SessionManager: """ Keeps track of active sessions and mapping from clients to sessions. """ def __init__(self) -> None: self._lock = threading.Lock() self._next_id = 1 self._sessions: Dict[int, GameSession] = {} self._by_client: Dict[int, int] = {} # client.id -> session.id def create_session(self, p1: ServerClient, p2: ServerClient) -> GameSession: with self._lock: sid = self._next_id self._next_id += 1 state = new_game_state() session = GameSession( id=sid, player1=p1, player2=p2, state=state, current_player=Player.PLAYER1, lock=threading.Lock(), ) self._sessions[sid] = session self._by_client[p1.id] = sid self._by_client[p2.id] = sid return session def session_for_client(self, client: ServerClient) -> Optional[GameSession]: with self._lock: sid = self._by_client.get(client.id) if sid is None: return None return self._sessions.get(sid) def end_session(self, session: GameSession) -> None: with self._lock: self._sessions.pop(session.id, None) self._by_client.pop(session.player1.id, None) self._by_client.pop(session.player2.id, None)