Coverage for src / beaverbunch / network / server.py: 100.0%
148 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 00:24 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-09 00:24 +0000
1from __future__ import annotations
3import threading
4from dataclasses import dataclass, field
5from typing import Any
7from beaverbunch.core.rules import RuleViolation
8from beaverbunch.network.protocol import (
9 BeaverRequest,
10 BeaverResponse,
11 CloseSessionRequest,
12 CloseSessionResponse,
13 CreateSessionRequest,
14 CreateSessionResponse,
15 DiscardDrawnRequest,
16 DiscardDrawnResponse,
17 DrawCardRequest,
18 DrawCardResponse,
19 DrawDiscardRequest,
20 DrawDiscardResponse,
21 ErrorResponse,
22 GameCardSlot,
23 GameSnapshot,
24 GetGameRequest,
25 GetGameResponse,
26 GetSessionRequest,
27 GetSessionResponse,
28 JoinSessionRequest,
29 JoinSessionResponse,
30 KeepCardRequest,
31 KeepCardResponse,
32 LeaveSessionRequest,
33 LeaveSessionResponse,
34 PeekInitialRequest,
35 PeekInitialResponse,
36 PeekOwnRequest,
37 PeekOwnResponse,
38 SessionSnapshot,
39 SkipBonusRequest,
40 SkipBonusResponse,
41 SnapRequest,
42 SnapResponse,
43 StartSessionRequest,
44 StartSessionResponse,
45 SwapCardRequest,
46 SwapCardResponse,
47 request_from_dict,
48)
49from beaverbunch.network.session import (
50 DuplicatePlayerError,
51 InvalidTokenError,
52 NotInSessionError,
53 SessionCapacityError,
54 SessionError,
55 SessionManager,
56 SessionNotFoundError,
57 SessionStateError,
58)
61@dataclass
62class SessionServer:
63 """Thin message dispatcher around SessionManager for local or future remote transports."""
65 manager: SessionManager = field(default_factory=SessionManager)
66 _lock: threading.RLock = field(default_factory=threading.RLock, init=False, repr=False)
68 def handle(self, message: dict[str, Any]) -> dict[str, Any]:
69 """Handle one protocol request and return a JSON-compatible response dict.
71 The server is process-local shared state in the FastAPI app. Serializing
72 request handling prevents concurrent request interleavings from mutating
73 lobby / game state while another request is still reading or snapshotting it.
74 """
75 with self._lock:
76 try:
77 request = request_from_dict(message)
78 except (KeyError, TypeError, ValueError) as exc:
79 return ErrorResponse(error_code="invalid_request", message=str(exc)).to_dict()
81 try:
82 response: CreateSessionResponse | JoinSessionResponse | StartSessionResponse | GetSessionResponse | \
83 LeaveSessionResponse | CloseSessionResponse | GetGameResponse | PeekInitialResponse | \
84 DrawCardResponse | DrawDiscardResponse | KeepCardResponse | DiscardDrawnResponse | \
85 PeekOwnResponse | SwapCardResponse | SkipBonusResponse | SnapResponse | BeaverResponse
86 if isinstance(request, CreateSessionRequest):
87 response = self._handle_create_session(request)
88 elif isinstance(request, JoinSessionRequest):
89 response = self._handle_join_session(request)
90 elif isinstance(request, StartSessionRequest):
91 response = self._handle_start_session(request)
92 elif isinstance(request, GetSessionRequest):
93 response = self._handle_get_session(request)
94 elif isinstance(request, LeaveSessionRequest):
95 response = self._handle_leave_session(request)
96 elif isinstance(request, CloseSessionRequest):
97 response = self._handle_close_session(request)
98 elif isinstance(request, GetGameRequest):
99 response = self._handle_get_game(request)
100 elif isinstance(request, PeekInitialRequest):
101 response = self._handle_peek_initial(request)
102 elif isinstance(request, DrawCardRequest):
103 response = self._handle_draw_card(request)
104 elif isinstance(request, DrawDiscardRequest):
105 response = self._handle_draw_discard(request)
106 elif isinstance(request, KeepCardRequest):
107 response = self._handle_keep_card(request)
108 elif isinstance(request, DiscardDrawnRequest):
109 response = self._handle_discard_drawn(request)
110 elif isinstance(request, PeekOwnRequest):
111 response = self._handle_peek_own(request)
112 elif isinstance(request, SwapCardRequest):
113 response = self._handle_swap_card(request)
114 elif isinstance(request, SkipBonusRequest):
115 response = self._handle_skip_bonus(request)
116 elif isinstance(request, SnapRequest):
117 response = self._handle_snap(request)
118 elif isinstance(request, BeaverRequest):
119 response = self._handle_beaver(request)
120 else: # pragma: no cover
121 return ErrorResponse(
122 error_code="invalid_request",
123 message=f"Unsupported request type: {type(request).__name__}",
124 ).to_dict()
125 except SessionNotFoundError as exc:
126 return ErrorResponse(error_code="session_not_found", message=str(exc)).to_dict()
127 except InvalidTokenError as exc:
128 return ErrorResponse(error_code="invalid_token", message=str(exc)).to_dict()
129 except DuplicatePlayerError as exc:
130 return ErrorResponse(error_code="duplicate_player", message=str(exc)).to_dict()
131 except NotInSessionError as exc: # pragma: no cover
132 return ErrorResponse(error_code="not_in_session", message=str(exc)).to_dict()
133 except SessionCapacityError as exc:
134 return ErrorResponse(error_code="capacity_exceeded", message=str(exc)).to_dict()
135 except SessionStateError as exc:
136 return ErrorResponse(error_code="invalid_session_state", message=str(exc)).to_dict()
137 except RuleViolation as exc:
138 return ErrorResponse(error_code="rule_violation", message=str(exc)).to_dict()
139 except (SessionError, ValueError) as exc:
140 return ErrorResponse(error_code="invalid_request", message=str(exc)).to_dict()
142 return response.to_dict()
144 def _handle_create_session(self, request: CreateSessionRequest) -> CreateSessionResponse:
145 session, token = self.manager.create_session(
146 host_name=request.host_name,
147 settings=request.settings.to_settings() if request.settings is not None else None,
148 )
149 return CreateSessionResponse(session=SessionSnapshot.from_session(session), player_token=token)
151 def _handle_join_session(self, request: JoinSessionRequest) -> JoinSessionResponse:
152 session, token = self.manager.join_session(request.join_code, request.player_name)
153 return JoinSessionResponse(session=SessionSnapshot.from_session(session), player_token=token)
155 def _handle_start_session(self, request: StartSessionRequest) -> StartSessionResponse:
156 self.manager.start_session(request.join_code, request.player_token)
157 session = self.manager.get_session(request.join_code)
158 return StartSessionResponse(session=SessionSnapshot.from_session(session))
160 def _handle_get_session(self, request: GetSessionRequest) -> GetSessionResponse:
161 session = self.manager.get_session(request.join_code)
162 return GetSessionResponse(session=SessionSnapshot.from_session(session))
164 def _handle_leave_session(self, request: LeaveSessionRequest) -> LeaveSessionResponse:
165 session = self.manager.leave_session(request.join_code, request.player_token)
166 snapshot = SessionSnapshot.from_session(session) if session is not None else None
167 return LeaveSessionResponse(session=snapshot)
169 def _handle_close_session(self, request: CloseSessionRequest) -> CloseSessionResponse:
170 session = self.manager.close_session(request.join_code, request.player_token)
171 return CloseSessionResponse(session=SessionSnapshot.from_session(session))
173 # ------------------------------------------------------------------
174 # Game-phase handlers
175 # ------------------------------------------------------------------
177 def _get_game_and_player(self, join_code: str, player_token: str):
178 """Return (game, player_name) or raise the appropriate session error."""
179 session = self.manager.get_session(join_code)
180 player_name = session.resolve_token(player_token)
181 if session.game is None:
182 from beaverbunch.network.session import SessionStateError
183 raise SessionStateError("The game has not started yet")
184 return session.game, player_name
186 def _handle_get_game(self, request: GetGameRequest) -> GetGameResponse:
187 game, player_name = self._get_game_and_player(request.join_code, request.player_token)
188 return GetGameResponse(game=GameSnapshot.from_game(game, player_name))
190 def _handle_peek_initial(self, request: PeekInitialRequest) -> PeekInitialResponse:
191 game, player_name = self._get_game_and_player(request.join_code, request.player_token)
192 peeked_cards = game.peek_initial(player_name, list(request.indices))
193 card_slots = [
194 GameCardSlot(known=True, suit=c.suit.value if c.suit else None, value=c.value)
195 for c in peeked_cards
196 ]
197 return PeekInitialResponse(cards=card_slots, game=GameSnapshot.from_game(game, player_name))
199 def _handle_draw_card(self, request: DrawCardRequest) -> DrawCardResponse:
200 game, player_name = self._get_game_and_player(request.join_code, request.player_token)
201 card = game.draw_card(player_name)
202 card_slot = GameCardSlot(known=True, suit=card.suit.value if card.suit else None, value=card.value)
203 return DrawCardResponse(card=card_slot, game=GameSnapshot.from_game(game, player_name))
205 def _handle_draw_discard(self, request: DrawDiscardRequest) -> DrawDiscardResponse:
206 game, player_name = self._get_game_and_player(request.join_code, request.player_token)
207 card = game.draw_from_discard(player_name)
208 card_slot = GameCardSlot(known=True, suit=card.suit.value if card.suit else None, value=card.value)
209 return DrawDiscardResponse(card=card_slot, game=GameSnapshot.from_game(game, player_name))
211 def _handle_keep_card(self, request: KeepCardRequest) -> KeepCardResponse:
212 from beaverbunch.core.actions import ReplaceCardAction
213 game, player_name = self._get_game_and_player(request.join_code, request.player_token)
214 old_card = game.keep_drawn_card(ReplaceCardAction(player_id=player_name, hand_index=request.hand_index))
215 discarded_slot = GameCardSlot(
216 known=True, suit=old_card.suit.value if old_card.suit else None, value=old_card.value,
217 )
218 return KeepCardResponse(discarded_card=discarded_slot, game=GameSnapshot.from_game(game, player_name))
220 def _handle_discard_drawn(self, request: DiscardDrawnRequest) -> DiscardDrawnResponse:
221 from beaverbunch.core.actions import DiscardDrawnAction
222 game, player_name = self._get_game_and_player(request.join_code, request.player_token)
223 bonus = game.discard_drawn_card(DiscardDrawnAction(player_id=player_name))
224 bonus_str = bonus.name if bonus is not None else None
225 return DiscardDrawnResponse(bonus=bonus_str, game=GameSnapshot.from_game(game, player_name))
227 def _handle_peek_own(self, request: PeekOwnRequest) -> PeekOwnResponse:
228 from beaverbunch.core.actions import PeekOwnCardAction
229 game, player_name = self._get_game_and_player(request.join_code, request.player_token)
230 card = game.execute_peek(PeekOwnCardAction(player_id=player_name, hand_index=request.hand_index))
231 card_slot = GameCardSlot(known=True, suit=card.suit.value if card.suit else None, value=card.value)
232 return PeekOwnResponse(card=card_slot, game=GameSnapshot.from_game(game, player_name))
234 def _handle_swap_card(self, request: SwapCardRequest) -> SwapCardResponse:
235 from beaverbunch.core.actions import SwapCardAction
236 game, player_name = self._get_game_and_player(request.join_code, request.player_token)
237 game.execute_swap(
238 SwapCardAction(
239 player_id=player_name,
240 own_index=request.own_index,
241 target_player_id=request.target_player_name,
242 target_index=request.target_index,
243 ),
244 )
245 return SwapCardResponse(game=GameSnapshot.from_game(game, player_name))
247 def _handle_skip_bonus(self, request: SkipBonusRequest) -> SkipBonusResponse:
248 game, player_name = self._get_game_and_player(request.join_code, request.player_token)
249 game.skip_bonus(player_name)
250 return SkipBonusResponse(game=GameSnapshot.from_game(game, player_name))
252 def _handle_snap(self, request: SnapRequest) -> SnapResponse:
253 from beaverbunch.core.actions import SnapAction
254 game, player_name = self._get_game_and_player(request.join_code, request.player_token)
255 correct = game.snap(SnapAction(player_id=player_name, hand_index=request.hand_index))
256 return SnapResponse(correct=correct, game=GameSnapshot.from_game(game, player_name))
258 def _handle_beaver(self, request: BeaverRequest) -> BeaverResponse:
259 game, player_name = self._get_game_and_player(request.join_code, request.player_token)
260 game.trigger_last_round(player_name)
261 return BeaverResponse(game=GameSnapshot.from_game(game, player_name))