238 lines
8.9 KiB
Python
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)
|