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