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

1from __future__ import annotations 

2 

3import threading 

4from dataclasses import dataclass, field 

5from typing import Any 

6 

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) 

59 

60 

61@dataclass 

62class SessionServer: 

63 """Thin message dispatcher around SessionManager for local or future remote transports.""" 

64 

65 manager: SessionManager = field(default_factory=SessionManager) 

66 _lock: threading.RLock = field(default_factory=threading.RLock, init=False, repr=False) 

67 

68 def handle(self, message: dict[str, Any]) -> dict[str, Any]: 

69 """Handle one protocol request and return a JSON-compatible response dict. 

70 

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

80 

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

141 

142 return response.to_dict() 

143 

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) 

150 

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) 

154 

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

159 

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

163 

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) 

168 

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

172 

173 # ------------------------------------------------------------------ 

174 # Game-phase handlers 

175 # ------------------------------------------------------------------ 

176 

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 

185 

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

189 

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

198 

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

204 

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

210 

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

219 

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

226 

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

233 

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

246 

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

251 

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

257 

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