quarto_gen/quarto/server/session.py
2026-02-13 20:40:40 +01:00

238 lines
8.9 KiB
Python

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)