from __future__ import annotations import argparse import threading import sys from typing import Optional from .net import ServerListener, ServerClient from .protocol import ( parse_client_line, ClientHello, ClientQueue, ClientMove, UnknownCommand, format_hello, ) from .match import Matchmaker from .session import SessionManager class ServerApp: def __init__(self, port: int) -> None: self.port = port self.clients: list[ServerClient] = [] self.listener = ServerListener(port) self.matchmaker = Matchmaker() self.sessions = SessionManager() def run(self) -> None: def on_new_client(client: ServerClient) -> None: self.clients.append(client) t = threading.Thread(target=self._handle_client, args=(client,), daemon=True) t.start() self.listener.accept_loop(on_new_client) def _handle_client(self, client: ServerClient) -> None: # Expect HELLO line = client.read_line() if line is None: client.close() return cmd = parse_client_line(line) if isinstance(cmd, ClientHello): desired_desc = cmd.description or "" # Enforce unique descriptions: if name is already taken, add suffix existing_names = {c.description for c in self.clients if c.description is not None} final_desc = desired_desc if final_desc in existing_names: i = 2 while f"{desired_desc}#{i}" in existing_names: i += 1 final_desc = f"{desired_desc}#{i}" client.description = final_desc client.send_line(format_hello("QuartoServer")) print( f"[SERVER] Client #{client.id} connected from {client.addr}, " f"description='{client.description}' (requested '{desired_desc}')", file=sys.stderr, ) else: client.close() return try: while True: line = client.read_line() if line is None: # disconnect self._handle_disconnect(client) break cmd = parse_client_line(line) if isinstance(cmd, ClientQueue): self._handle_queue(client) elif isinstance(cmd, ClientMove): self._handle_move(client, cmd) elif isinstance(cmd, UnknownCommand): # Silently ignore unknown commands continue else: # Unknown type; ignore continue finally: client.close() def _handle_queue(self, client: ServerClient) -> None: # If already in a game, ignore if client.in_game: return self.matchmaker.enqueue(client) pair = self.matchmaker.dequeue_pair() if pair is None: return c1, c2 = pair c1.in_game = True c2.in_game = True session = self.sessions.create_session(c1, c2) session.send_newgame() # LOG: new game created print( f"[SERVER] New game #{session.id} created: " f"P1=#{c1.id} '{session.player1_name}' vs " f"P2=#{c2.id} '{session.player2_name}'", file=sys.stderr, ) def _handle_move(self, client: ServerClient, move: ClientMove) -> None: session = self.sessions.session_for_client(client) if session is None: # Not in a game; ignore return # LOG: raw MOVE from client print( f"[SERVER] Game #{session.id} - Client #{client.id} " f"('{session.player1_name if client is session.player1 else session.player2_name}') " f"sends MOVE: square={move.square}, m_value={move.m_value}", file=sys.stderr, ) session.handle_client_move(client, move) # If session ended, clean up if session.state.game_over: self.sessions.end_session(session) session.player1.in_game = False session.player2.in_game = False def _handle_disconnect(self, client: ServerClient) -> None: # If in a game, inform session session = self.sessions.session_for_client(client) if session is not None: session.handle_disconnect(client) self.sessions.end_session(session) session.player1.in_game = False session.player2.in_game = False # If in lobby, remove from queue self.matchmaker.remove(client) if client in self.clients: self.clients.remove(client) def main() -> None: parser = argparse.ArgumentParser(description="Quarto tournament server") parser.add_argument("--port", type=int, default=5000, help="Listen port") args = parser.parse_args() app = ServerApp(args.port) try: app.run() except KeyboardInterrupt: print("Server shutting down", file=sys.stderr) if __name__ == "__main__": main()