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

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

61 

62 

63@dataclass 

64class SessionServer: 

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

66 

67 manager: SessionManager = field(default_factory=SessionManager) 

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

69 

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

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

72 

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

82 

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

146 

147 return response.to_dict() 

148 

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) 

155 

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) 

159 

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

164 

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

168 

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) 

173 

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

177 

178 # ------------------------------------------------------------------ 

179 # Game-phase handlers 

180 # ------------------------------------------------------------------ 

181 

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 

190 

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

194 

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

203 

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

209 

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

215 

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

224 

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

231 

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

238 

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

251 

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

256 

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

262 

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

267 

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