quarto_gen/quarto/server/core.py
2026-02-13 21:25:05 +01:00

159 lines
5.1 KiB
Python

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