Coverage for src / beaverbunch / network / protocol.py: 100.0%

540 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-09 19:37 +0000

1from __future__ import annotations 

2 

3from dataclasses import asdict, dataclass 

4from typing import Any, Callable, ClassVar, Literal 

5 

6from beaverbunch.core.game_settings import GameSettings 

7from beaverbunch.network.session import Session 

8 

9RequestType = Literal[ 

10 "create_session", 

11 "join_session", 

12 "start_session", 

13 "get_session", 

14 "leave_session", 

15 "close_session", 

16 "get_game", 

17 "peek_initial", 

18 "draw_card", 

19 "draw_discard", 

20 "keep_card", 

21 "discard_drawn", 

22 "peek_own", 

23 "swap_card", 

24 "skip_bonus", 

25 "snap", 

26 "beaver", 

27 "king_bonus", 

28] 

29ResponseType = Literal[ 

30 "create_session_response", 

31 "join_session_response", 

32 "start_session_response", 

33 "get_session_response", 

34 "leave_session_response", 

35 "close_session_response", 

36 "error", 

37 "get_game_response", 

38 "peek_initial_response", 

39 "draw_card_response", 

40 "draw_discard_response", 

41 "keep_card_response", 

42 "discard_drawn_response", 

43 "peek_own_response", 

44 "swap_card_response", 

45 "skip_bonus_response", 

46 "snap_response", 

47 "beaver_response", 

48 "king_bonus_response", 

49] 

50 

51 

52@dataclass(frozen=True) 

53class SessionSettingsPayload: 

54 """Serializable subset of GameSettings used by the lobby protocol.""" 

55 

56 initial_hand_size: int 

57 initial_cards_known: int 

58 min_players: int 

59 max_players: int 

60 

61 @classmethod 

62 def from_settings(cls, settings: GameSettings) -> SessionSettingsPayload: 

63 return cls( 

64 initial_hand_size=settings.initial_hand_size, 

65 initial_cards_known=settings.initial_cards_known, 

66 min_players=settings.min_players, 

67 max_players=settings.max_players, 

68 ) 

69 

70 @classmethod 

71 def from_dict(cls, payload: dict[str, Any]) -> SessionSettingsPayload: 

72 return cls( 

73 initial_hand_size=payload.get("initial_hand_size", GameSettings.initial_hand_size), 

74 initial_cards_known=payload.get("initial_cards_known", GameSettings.initial_cards_known), 

75 min_players=payload.get("min_players", GameSettings.min_players), 

76 max_players=payload.get("max_players", GameSettings.max_players), 

77 ) 

78 

79 def to_settings(self) -> GameSettings: 

80 return GameSettings( 

81 initial_hand_size=self.initial_hand_size, 

82 initial_cards_known=self.initial_cards_known, 

83 min_players=self.min_players, 

84 max_players=self.max_players, 

85 ) 

86 

87 def to_dict(self) -> dict[str, Any]: 

88 return asdict(self) 

89 

90 

91@dataclass(frozen=True) 

92class SessionSnapshot: 

93 """Serializable view of the current lobby state.""" 

94 

95 join_code: str 

96 host_name: str | None 

97 player_names: list[str] 

98 player_count: int 

99 lobby_state: str 

100 can_start: bool 

101 settings: SessionSettingsPayload 

102 game_phase: str | None = None 

103 

104 @classmethod 

105 def from_session(cls, session: Session) -> SessionSnapshot: 

106 game_phase = None 

107 if session.game is not None: 

108 game_phase = session.game.state.phase.name 

109 

110 return cls( 

111 join_code=session.join_code, 

112 host_name=session.host_name, 

113 player_names=[player.name for player in session.players], 

114 player_count=session.player_count, 

115 lobby_state=session.lobby_state.name, 

116 can_start=session.can_start(), 

117 settings=SessionSettingsPayload.from_settings(session.settings), 

118 game_phase=game_phase, 

119 ) 

120 

121 @classmethod 

122 def from_dict(cls, payload: dict[str, Any]) -> SessionSnapshot: 

123 return cls( 

124 join_code=payload["join_code"], 

125 host_name=payload.get("host_name"), 

126 player_names=list(payload.get("player_names", [])), 

127 player_count=payload["player_count"], 

128 lobby_state=payload["lobby_state"], 

129 can_start=payload["can_start"], 

130 settings=SessionSettingsPayload.from_dict(payload["settings"]), 

131 game_phase=payload.get("game_phase"), 

132 ) 

133 

134 def to_dict(self) -> dict[str, Any]: 

135 return { 

136 "join_code": self.join_code, 

137 "host_name": self.host_name, 

138 "player_names": list(self.player_names), 

139 "player_count": self.player_count, 

140 "lobby_state": self.lobby_state, 

141 "can_start": self.can_start, 

142 "settings": self.settings.to_dict(), 

143 "game_phase": self.game_phase, 

144 } 

145 

146 

147@dataclass(frozen=True) 

148class CreateSessionRequest: 

149 type: ClassVar[str] = "create_session" 

150 host_name: str | None = None 

151 settings: SessionSettingsPayload | None = None 

152 

153 @classmethod 

154 def from_dict(cls, payload: dict[str, Any]) -> CreateSessionRequest: 

155 _require_type(payload, cls.type) 

156 raw_settings = payload.get("settings") 

157 return cls( 

158 host_name=payload.get("host_name"), 

159 settings=SessionSettingsPayload.from_dict(raw_settings) if raw_settings is not None else None, 

160 ) 

161 

162 def to_dict(self) -> dict[str, Any]: 

163 result: dict[str, Any] = {"type": self.type, "host_name": self.host_name} 

164 if self.settings is not None: 

165 result["settings"] = self.settings.to_dict() 

166 return result 

167 

168 

169@dataclass(frozen=True) 

170class JoinSessionRequest: 

171 type: ClassVar[str] = "join_session" 

172 join_code: str = "" 

173 player_name: str = "" 

174 

175 @classmethod 

176 def from_dict(cls, payload: dict[str, Any]) -> JoinSessionRequest: 

177 _require_type(payload, cls.type) 

178 return cls(join_code=payload["join_code"], player_name=payload["player_name"]) 

179 

180 def to_dict(self) -> dict[str, Any]: 

181 return {"type": self.type, "join_code": self.join_code, "player_name": self.player_name} 

182 

183 

184@dataclass(frozen=True) 

185class StartSessionRequest: 

186 type: ClassVar[str] = "start_session" 

187 join_code: str = "" 

188 player_token: str = "" 

189 

190 @classmethod 

191 def from_dict(cls, payload: dict[str, Any]) -> StartSessionRequest: 

192 _require_type(payload, cls.type) 

193 return cls(join_code=payload["join_code"], player_token=payload["player_token"]) 

194 

195 def to_dict(self) -> dict[str, Any]: 

196 return {"type": self.type, "join_code": self.join_code, "player_token": self.player_token} 

197 

198 

199@dataclass(frozen=True) 

200class GetSessionRequest: 

201 type: ClassVar[str] = "get_session" 

202 join_code: str = "" 

203 

204 @classmethod 

205 def from_dict(cls, payload: dict[str, Any]) -> GetSessionRequest: 

206 _require_type(payload, cls.type) 

207 return cls(join_code=payload["join_code"]) 

208 

209 def to_dict(self) -> dict[str, Any]: 

210 return {"type": self.type, "join_code": self.join_code} 

211 

212 

213@dataclass(frozen=True) 

214class LeaveSessionRequest: 

215 type: ClassVar[str] = "leave_session" 

216 join_code: str = "" 

217 player_token: str = "" 

218 

219 @classmethod 

220 def from_dict(cls, payload: dict[str, Any]) -> LeaveSessionRequest: 

221 _require_type(payload, cls.type) 

222 return cls(join_code=payload["join_code"], player_token=payload["player_token"]) 

223 

224 def to_dict(self) -> dict[str, Any]: 

225 return {"type": self.type, "join_code": self.join_code, "player_token": self.player_token} 

226 

227 

228@dataclass(frozen=True) 

229class CloseSessionRequest: 

230 type: ClassVar[str] = "close_session" 

231 join_code: str = "" 

232 player_token: str = "" 

233 

234 @classmethod 

235 def from_dict(cls, payload: dict[str, Any]) -> CloseSessionRequest: 

236 _require_type(payload, cls.type) 

237 return cls(join_code=payload["join_code"], player_token=payload["player_token"]) 

238 

239 def to_dict(self) -> dict[str, Any]: 

240 return {"type": self.type, "join_code": self.join_code, "player_token": self.player_token} 

241 

242 

243@dataclass(frozen=True) 

244class CreateSessionResponse: 

245 type: ClassVar[str] = "create_session_response" 

246 session: SessionSnapshot 

247 player_token: str | None = None 

248 

249 @classmethod 

250 def from_dict(cls, payload: dict[str, Any]) -> CreateSessionResponse: 

251 _require_type(payload, cls.type) 

252 return cls( 

253 session=SessionSnapshot.from_dict(payload["session"]), 

254 player_token=payload.get("player_token"), 

255 ) 

256 

257 def to_dict(self) -> dict[str, Any]: 

258 return {"type": self.type, "player_token": self.player_token, "session": self.session.to_dict()} 

259 

260 

261@dataclass(frozen=True) 

262class JoinSessionResponse: 

263 type: ClassVar[str] = "join_session_response" 

264 session: SessionSnapshot 

265 player_token: str = "" 

266 

267 @classmethod 

268 def from_dict(cls, payload: dict[str, Any]) -> JoinSessionResponse: 

269 _require_type(payload, cls.type) 

270 return cls( 

271 session=SessionSnapshot.from_dict(payload["session"]), 

272 player_token=payload.get("player_token", ""), 

273 ) 

274 

275 def to_dict(self) -> dict[str, Any]: 

276 return {"type": self.type, "player_token": self.player_token, "session": self.session.to_dict()} 

277 

278 

279@dataclass(frozen=True) 

280class StartSessionResponse: 

281 type: ClassVar[str] = "start_session_response" 

282 session: SessionSnapshot 

283 

284 @classmethod 

285 def from_dict(cls, payload: dict[str, Any]) -> StartSessionResponse: 

286 _require_type(payload, cls.type) 

287 return cls(session=SessionSnapshot.from_dict(payload["session"])) 

288 

289 def to_dict(self) -> dict[str, Any]: 

290 return {"type": self.type, "session": self.session.to_dict()} 

291 

292 

293@dataclass(frozen=True) 

294class GetSessionResponse: 

295 type: ClassVar[str] = "get_session_response" 

296 session: SessionSnapshot 

297 

298 @classmethod 

299 def from_dict(cls, payload: dict[str, Any]) -> GetSessionResponse: 

300 _require_type(payload, cls.type) 

301 return cls(session=SessionSnapshot.from_dict(payload["session"])) 

302 

303 def to_dict(self) -> dict[str, Any]: 

304 return {"type": self.type, "session": self.session.to_dict()} 

305 

306 

307@dataclass(frozen=True) 

308class LeaveSessionResponse: 

309 type: ClassVar[str] = "leave_session_response" 

310 session: SessionSnapshot | None 

311 

312 @classmethod 

313 def from_dict(cls, payload: dict[str, Any]) -> LeaveSessionResponse: 

314 _require_type(payload, cls.type) 

315 raw = payload.get("session") 

316 return cls(session=SessionSnapshot.from_dict(raw) if raw is not None else None) 

317 

318 def to_dict(self) -> dict[str, Any]: 

319 result: dict[str, Any] = { 

320 "type": self.type, "session": self.session.to_dict() if self.session is not None else None, 

321 } 

322 return result 

323 

324 

325@dataclass(frozen=True) 

326class CloseSessionResponse: 

327 type: ClassVar[str] = "close_session_response" 

328 session: SessionSnapshot 

329 

330 @classmethod 

331 def from_dict(cls, payload: dict[str, Any]) -> CloseSessionResponse: 

332 _require_type(payload, cls.type) 

333 return cls(session=SessionSnapshot.from_dict(payload["session"])) 

334 

335 def to_dict(self) -> dict[str, Any]: 

336 return {"type": self.type, "session": self.session.to_dict()} 

337 

338 

339@dataclass(frozen=True) 

340class ErrorResponse: 

341 type: ClassVar[str] = "error" 

342 error_code: str 

343 message: str 

344 

345 @classmethod 

346 def from_dict(cls, payload: dict[str, Any]) -> ErrorResponse: 

347 _require_type(payload, cls.type) 

348 return cls(error_code=payload["error_code"], message=payload["message"]) 

349 

350 def to_dict(self) -> dict[str, Any]: 

351 return {"type": self.type, "error_code": self.error_code, "message": self.message} 

352 

353 

354def request_from_dict( 

355 payload: dict[str, Any], 

356) -> (CreateSessionRequest | JoinSessionRequest | StartSessionRequest | GetSessionRequest | LeaveSessionRequest | 

357 CloseSessionRequest | GetGameRequest | PeekInitialRequest | DrawCardRequest | DrawDiscardRequest | 

358 KeepCardRequest | DiscardDrawnRequest | PeekOwnRequest | SwapCardRequest | SkipBonusRequest | SnapRequest | 

359 BeaverRequest | KingBonusRequest): 

360 """Parse a protocol request dict into its typed dataclass.""" 

361 message_type = payload.get("type") 

362 if not isinstance(message_type, str): 

363 raise ValueError(f"Unknown request type: {message_type}") 

364 

365 request_types: dict[str, Callable[[dict[str, Any]], Any]] = { 

366 CreateSessionRequest.type: CreateSessionRequest.from_dict, 

367 JoinSessionRequest.type: JoinSessionRequest.from_dict, 

368 StartSessionRequest.type: StartSessionRequest.from_dict, 

369 GetSessionRequest.type: GetSessionRequest.from_dict, 

370 LeaveSessionRequest.type: LeaveSessionRequest.from_dict, 

371 CloseSessionRequest.type: CloseSessionRequest.from_dict, 

372 GetGameRequest.type: GetGameRequest.from_dict, 

373 PeekInitialRequest.type: PeekInitialRequest.from_dict, 

374 DrawCardRequest.type: DrawCardRequest.from_dict, 

375 DrawDiscardRequest.type: DrawDiscardRequest.from_dict, 

376 KeepCardRequest.type: KeepCardRequest.from_dict, 

377 DiscardDrawnRequest.type: DiscardDrawnRequest.from_dict, 

378 PeekOwnRequest.type: PeekOwnRequest.from_dict, 

379 SwapCardRequest.type: SwapCardRequest.from_dict, 

380 SkipBonusRequest.type: SkipBonusRequest.from_dict, 

381 SnapRequest.type: SnapRequest.from_dict, 

382 BeaverRequest.type: BeaverRequest.from_dict, 

383 KingBonusRequest.type: KingBonusRequest.from_dict, 

384 } 

385 request_parser = request_types.get(message_type) 

386 if request_parser is None: 

387 raise ValueError(f"Unknown request type: {message_type}") 

388 return request_parser(payload) 

389 

390 

391def response_from_dict( 

392 payload: dict[str, Any], 

393) -> (CreateSessionResponse | JoinSessionResponse | StartSessionResponse | GetSessionResponse | LeaveSessionResponse | 

394 CloseSessionResponse | ErrorResponse | GetGameResponse | PeekInitialResponse | DrawCardResponse | 

395 DrawDiscardResponse | KeepCardResponse | DiscardDrawnResponse | PeekOwnResponse | SwapCardResponse | 

396 SkipBonusResponse | SnapResponse | BeaverResponse | KingBonusResponse): 

397 """Parse a protocol response dict into its typed dataclass.""" 

398 message_type = payload.get("type") 

399 if not isinstance(message_type, str): 

400 raise ValueError(f"Unknown response type: {message_type}") 

401 

402 response_types: dict[str, Callable[[dict[str, Any]], Any]] = { 

403 CreateSessionResponse.type: CreateSessionResponse.from_dict, 

404 JoinSessionResponse.type: JoinSessionResponse.from_dict, 

405 StartSessionResponse.type: StartSessionResponse.from_dict, 

406 GetSessionResponse.type: GetSessionResponse.from_dict, 

407 LeaveSessionResponse.type: LeaveSessionResponse.from_dict, 

408 CloseSessionResponse.type: CloseSessionResponse.from_dict, 

409 ErrorResponse.type: ErrorResponse.from_dict, 

410 GetGameResponse.type: GetGameResponse.from_dict, 

411 PeekInitialResponse.type: PeekInitialResponse.from_dict, 

412 DrawCardResponse.type: DrawCardResponse.from_dict, 

413 DrawDiscardResponse.type: DrawDiscardResponse.from_dict, 

414 KeepCardResponse.type: KeepCardResponse.from_dict, 

415 DiscardDrawnResponse.type: DiscardDrawnResponse.from_dict, 

416 PeekOwnResponse.type: PeekOwnResponse.from_dict, 

417 SwapCardResponse.type: SwapCardResponse.from_dict, 

418 SkipBonusResponse.type: SkipBonusResponse.from_dict, 

419 SnapResponse.type: SnapResponse.from_dict, 

420 BeaverResponse.type: BeaverResponse.from_dict, 

421 KingBonusResponse.type: KingBonusResponse.from_dict, 

422 } 

423 response_parser = response_types.get(message_type) 

424 if response_parser is None: 

425 raise ValueError(f"Unknown response type: {message_type}") 

426 return response_parser(payload) 

427 

428 

429def _require_type(payload: dict[str, Any], expected_type: str) -> None: 

430 actual_type = payload.get("type") 

431 if actual_type != expected_type: 

432 raise ValueError(f"Expected message type '{expected_type}', got '{actual_type}'") 

433 

434 

435# --------------------------------------------------------------------------- 

436# Game-phase protocol types 

437# --------------------------------------------------------------------------- 

438 

439@dataclass(frozen=True) 

440class GameCardSlot: 

441 """Serializable view of a single card slot in a player's hand. 

442 

443 ``suit`` and ``value`` are ``None`` when the card is unknown to the 

444 requesting player (i.e. face-down from their perspective). 

445 """ 

446 

447 known: bool 

448 suit: str | None = None # e.g. "Hearts", None when unknown 

449 value: int | None = None # 1–13 (or 0 for Joker), None when unknown 

450 

451 def to_dict(self) -> dict[str, Any]: 

452 return {"known": self.known, "suit": self.suit, "value": self.value} 

453 

454 @classmethod 

455 def from_dict(cls, payload: dict[str, Any]) -> GameCardSlot: 

456 return cls( 

457 known=payload["known"], 

458 suit=payload.get("suit"), 

459 value=payload.get("value"), 

460 ) 

461 

462 

463@dataclass(frozen=True) 

464class PlayerGameView: 

465 """Serializable view of one player from another player's perspective.""" 

466 

467 name: str 

468 hand: list[GameCardSlot] 

469 is_current_turn: bool 

470 

471 def to_dict(self) -> dict[str, Any]: 

472 return { 

473 "name": self.name, 

474 "hand": [s.to_dict() for s in self.hand], 

475 "is_current_turn": self.is_current_turn, 

476 } 

477 

478 @classmethod 

479 def from_dict(cls, payload: dict[str, Any]) -> PlayerGameView: 

480 return cls( 

481 name=payload["name"], 

482 hand=[GameCardSlot.from_dict(s) for s in payload["hand"]], 

483 is_current_turn=payload["is_current_turn"], 

484 ) 

485 

486 

487@dataclass(frozen=True) 

488class GameSnapshot: 

489 """Perspective-aware view of the running game for a specific player.""" 

490 

491 phase: str 

492 current_player_name: str 

493 draw_pile_size: int 

494 top_discard: GameCardSlot | None 

495 drawn_card: GameCardSlot | None # only set for the requesting player 

496 pending_bonus: str | None # "JACK" | "QUEEN" | "KING" | None 

497 players: list[PlayerGameView] 

498 scores: list[dict[str, Any]] | None # only present when phase == "FINISHED" 

499 

500 def to_dict(self) -> dict[str, Any]: 

501 return { 

502 "phase": self.phase, 

503 "current_player_name": self.current_player_name, 

504 "draw_pile_size": self.draw_pile_size, 

505 "top_discard": self.top_discard.to_dict() if self.top_discard else None, 

506 "drawn_card": self.drawn_card.to_dict() if self.drawn_card else None, 

507 "pending_bonus": self.pending_bonus, 

508 "players": [p.to_dict() for p in self.players], 

509 "scores": self.scores, 

510 } 

511 

512 @classmethod 

513 def from_dict(cls, payload: dict[str, Any]) -> GameSnapshot: 

514 return cls( 

515 phase=payload["phase"], 

516 current_player_name=payload["current_player_name"], 

517 draw_pile_size=payload["draw_pile_size"], 

518 top_discard=GameCardSlot.from_dict(payload["top_discard"]) if payload.get("top_discard") else None, 

519 drawn_card=GameCardSlot.from_dict(payload["drawn_card"]) if payload.get("drawn_card") else None, 

520 pending_bonus=payload.get("pending_bonus"), 

521 players=[PlayerGameView.from_dict(p) for p in payload["players"]], 

522 scores=payload.get("scores"), 

523 ) 

524 

525 @classmethod 

526 def from_game( 

527 cls, 

528 game: "Any", # beaverbunch.core.game.Game 

529 requesting_player_name: str, 

530 ) -> "GameSnapshot": 

531 """Build a perspective-aware snapshot for ``requesting_player_name``.""" 

532 from beaverbunch.core.game import GamePhase 

533 

534 state = game.state 

535 

536 def _card_slot(card: "Any", known: bool) -> GameCardSlot: 

537 if card is None: 

538 return GameCardSlot(known=False) 

539 if known: 

540 suit_str = card.suit.value if card.suit is not None else None 

541 return GameCardSlot(known=True, suit=suit_str, value=card.value) 

542 return GameCardSlot(known=False) 

543 

544 player_views: list[PlayerGameView] = [] 

545 for player in state.players: 

546 is_me = player.name == requesting_player_name 

547 slots = [ 

548 _card_slot(slot.card, slot.known if is_me else False) 

549 for slot in player.hand.slots 

550 ] 

551 player_views.append( 

552 PlayerGameView( 

553 name=player.name, 

554 hand=slots, 

555 is_current_turn=(state.current_player.name == player.name), 

556 ), 

557 ) 

558 

559 # Top discard is always visible 

560 top_discard_slot: GameCardSlot | None = None 

561 if state.top_discard is not None: 

562 top_discard_slot = _card_slot(state.top_discard, known=True) 

563 

564 # Drawn card is only shown to the player currently holding it 

565 drawn_card_slot: GameCardSlot | None = None 

566 if state.drawn_card is not None and state.current_player.name == requesting_player_name: 

567 drawn_card_slot = _card_slot(state.drawn_card, known=True) 

568 

569 pending_bonus_str: str | None = None 

570 if state.pending_bonus is not None: 

571 pending_bonus_str = state.pending_bonus.name # "JACK" | "QUEEN" | "KING" 

572 

573 scores: list[dict[str, Any]] | None = None 

574 if state.phase == GamePhase.FINISHED: 

575 scores = [{"name": name, "score": score} for name, score in game.get_scores()] 

576 

577 return cls( 

578 phase=state.phase.name, 

579 current_player_name=state.current_player.name, 

580 draw_pile_size=len(state.deck), 

581 top_discard=top_discard_slot, 

582 drawn_card=drawn_card_slot, 

583 pending_bonus=pending_bonus_str, 

584 players=player_views, 

585 scores=scores, 

586 ) 

587 

588 

589# --- Game request/response dataclasses --- 

590 

591@dataclass(frozen=True) 

592class GetGameRequest: 

593 type: ClassVar[str] = "get_game" 

594 join_code: str = "" 

595 player_token: str = "" 

596 

597 @classmethod 

598 def from_dict(cls, payload: dict[str, Any]) -> GetGameRequest: 

599 _require_type(payload, cls.type) 

600 return cls(join_code=payload["join_code"], player_token=payload["player_token"]) 

601 

602 def to_dict(self) -> dict[str, Any]: 

603 return {"type": self.type, "join_code": self.join_code, "player_token": self.player_token} 

604 

605 

606@dataclass(frozen=True) 

607class GetGameResponse: 

608 type: ClassVar[str] = "get_game_response" 

609 game: GameSnapshot 

610 

611 @classmethod 

612 def from_dict(cls, payload: dict[str, Any]) -> GetGameResponse: 

613 _require_type(payload, cls.type) 

614 return cls(game=GameSnapshot.from_dict(payload["game"])) 

615 

616 def to_dict(self) -> dict[str, Any]: 

617 return {"type": self.type, "game": self.game.to_dict()} 

618 

619 

620@dataclass(frozen=True) 

621class PeekInitialRequest: 

622 type: ClassVar[str] = "peek_initial" 

623 join_code: str = "" 

624 player_token: str = "" 

625 indices: list[int] = () # type: ignore[assignment] 

626 

627 @classmethod 

628 def from_dict(cls, payload: dict[str, Any]) -> PeekInitialRequest: 

629 _require_type(payload, cls.type) 

630 return cls( 

631 join_code=payload["join_code"], 

632 player_token=payload["player_token"], 

633 indices=list(payload.get("indices", [])), 

634 ) 

635 

636 def to_dict(self) -> dict[str, Any]: 

637 return { 

638 "type": self.type, "join_code": self.join_code, "player_token": self.player_token, 

639 "indices": list(self.indices), 

640 } 

641 

642 

643@dataclass(frozen=True) 

644class PeekInitialResponse: 

645 type: ClassVar[str] = "peek_initial_response" 

646 cards: list[GameCardSlot] 

647 game: GameSnapshot 

648 

649 @classmethod 

650 def from_dict(cls, payload: dict[str, Any]) -> PeekInitialResponse: 

651 _require_type(payload, cls.type) 

652 return cls( 

653 cards=[GameCardSlot.from_dict(c) for c in payload["cards"]], 

654 game=GameSnapshot.from_dict(payload["game"]), 

655 ) 

656 

657 def to_dict(self) -> dict[str, Any]: 

658 return {"type": self.type, "cards": [c.to_dict() for c in self.cards], "game": self.game.to_dict()} 

659 

660 

661@dataclass(frozen=True) 

662class DrawCardRequest: 

663 type: ClassVar[str] = "draw_card" 

664 join_code: str = "" 

665 player_token: str = "" 

666 

667 @classmethod 

668 def from_dict(cls, payload: dict[str, Any]) -> DrawCardRequest: 

669 _require_type(payload, cls.type) 

670 return cls(join_code=payload["join_code"], player_token=payload["player_token"]) 

671 

672 def to_dict(self) -> dict[str, Any]: 

673 return {"type": self.type, "join_code": self.join_code, "player_token": self.player_token} 

674 

675 

676@dataclass(frozen=True) 

677class DrawCardResponse: 

678 type: ClassVar[str] = "draw_card_response" 

679 card: GameCardSlot 

680 game: GameSnapshot 

681 

682 @classmethod 

683 def from_dict(cls, payload: dict[str, Any]) -> DrawCardResponse: 

684 _require_type(payload, cls.type) 

685 return cls(card=GameCardSlot.from_dict(payload["card"]), game=GameSnapshot.from_dict(payload["game"])) 

686 

687 def to_dict(self) -> dict[str, Any]: 

688 return {"type": self.type, "card": self.card.to_dict(), "game": self.game.to_dict()} 

689 

690 

691@dataclass(frozen=True) 

692class DrawDiscardRequest: 

693 type: ClassVar[str] = "draw_discard" 

694 join_code: str = "" 

695 player_token: str = "" 

696 

697 @classmethod 

698 def from_dict(cls, payload: dict[str, Any]) -> DrawDiscardRequest: 

699 _require_type(payload, cls.type) 

700 return cls(join_code=payload["join_code"], player_token=payload["player_token"]) 

701 

702 def to_dict(self) -> dict[str, Any]: 

703 return {"type": self.type, "join_code": self.join_code, "player_token": self.player_token} 

704 

705 

706@dataclass(frozen=True) 

707class DrawDiscardResponse: 

708 type: ClassVar[str] = "draw_discard_response" 

709 card: GameCardSlot 

710 game: GameSnapshot 

711 

712 @classmethod 

713 def from_dict(cls, payload: dict[str, Any]) -> DrawDiscardResponse: 

714 _require_type(payload, cls.type) 

715 return cls(card=GameCardSlot.from_dict(payload["card"]), game=GameSnapshot.from_dict(payload["game"])) 

716 

717 def to_dict(self) -> dict[str, Any]: 

718 return {"type": self.type, "card": self.card.to_dict(), "game": self.game.to_dict()} 

719 

720 

721@dataclass(frozen=True) 

722class KeepCardRequest: 

723 type: ClassVar[str] = "keep_card" 

724 join_code: str = "" 

725 player_token: str = "" 

726 hand_index: int = 0 

727 

728 @classmethod 

729 def from_dict(cls, payload: dict[str, Any]) -> KeepCardRequest: 

730 _require_type(payload, cls.type) 

731 return cls( 

732 join_code=payload["join_code"], 

733 player_token=payload["player_token"], 

734 hand_index=int(payload["hand_index"]), 

735 ) 

736 

737 def to_dict(self) -> dict[str, Any]: 

738 return { 

739 "type": self.type, "join_code": self.join_code, "player_token": self.player_token, 

740 "hand_index": self.hand_index, 

741 } 

742 

743 

744@dataclass(frozen=True) 

745class KeepCardResponse: 

746 type: ClassVar[str] = "keep_card_response" 

747 discarded_card: GameCardSlot 

748 game: GameSnapshot 

749 

750 @classmethod 

751 def from_dict(cls, payload: dict[str, Any]) -> KeepCardResponse: 

752 _require_type(payload, cls.type) 

753 return cls( 

754 discarded_card=GameCardSlot.from_dict(payload["discarded_card"]), 

755 game=GameSnapshot.from_dict(payload["game"]), 

756 ) 

757 

758 def to_dict(self) -> dict[str, Any]: 

759 return {"type": self.type, "discarded_card": self.discarded_card.to_dict(), "game": self.game.to_dict()} 

760 

761 

762@dataclass(frozen=True) 

763class DiscardDrawnRequest: 

764 type: ClassVar[str] = "discard_drawn" 

765 join_code: str = "" 

766 player_token: str = "" 

767 

768 @classmethod 

769 def from_dict(cls, payload: dict[str, Any]) -> DiscardDrawnRequest: 

770 _require_type(payload, cls.type) 

771 return cls(join_code=payload["join_code"], player_token=payload["player_token"]) 

772 

773 def to_dict(self) -> dict[str, Any]: 

774 return {"type": self.type, "join_code": self.join_code, "player_token": self.player_token} 

775 

776 

777@dataclass(frozen=True) 

778class DiscardDrawnResponse: 

779 type: ClassVar[str] = "discard_drawn_response" 

780 bonus: str | None # "JACK" | "QUEEN" | "KING" | None 

781 game: GameSnapshot 

782 

783 @classmethod 

784 def from_dict(cls, payload: dict[str, Any]) -> DiscardDrawnResponse: 

785 _require_type(payload, cls.type) 

786 return cls(bonus=payload.get("bonus"), game=GameSnapshot.from_dict(payload["game"])) 

787 

788 def to_dict(self) -> dict[str, Any]: 

789 return {"type": self.type, "bonus": self.bonus, "game": self.game.to_dict()} 

790 

791 

792@dataclass(frozen=True) 

793class PeekOwnRequest: 

794 type: ClassVar[str] = "peek_own" 

795 join_code: str = "" 

796 player_token: str = "" 

797 hand_index: int = 0 

798 

799 @classmethod 

800 def from_dict(cls, payload: dict[str, Any]) -> PeekOwnRequest: 

801 _require_type(payload, cls.type) 

802 return cls( 

803 join_code=payload["join_code"], 

804 player_token=payload["player_token"], 

805 hand_index=int(payload["hand_index"]), 

806 ) 

807 

808 def to_dict(self) -> dict[str, Any]: 

809 return { 

810 "type": self.type, "join_code": self.join_code, "player_token": self.player_token, 

811 "hand_index": self.hand_index, 

812 } 

813 

814 

815@dataclass(frozen=True) 

816class PeekOwnResponse: 

817 type: ClassVar[str] = "peek_own_response" 

818 card: GameCardSlot 

819 game: GameSnapshot 

820 

821 @classmethod 

822 def from_dict(cls, payload: dict[str, Any]) -> PeekOwnResponse: 

823 _require_type(payload, cls.type) 

824 return cls(card=GameCardSlot.from_dict(payload["card"]), game=GameSnapshot.from_dict(payload["game"])) 

825 

826 def to_dict(self) -> dict[str, Any]: 

827 return {"type": self.type, "card": self.card.to_dict(), "game": self.game.to_dict()} 

828 

829 

830@dataclass(frozen=True) 

831class SwapCardRequest: 

832 type: ClassVar[str] = "swap_card" 

833 join_code: str = "" 

834 player_token: str = "" 

835 own_index: int = 0 

836 target_player_name: str = "" 

837 target_index: int = 0 

838 

839 @classmethod 

840 def from_dict(cls, payload: dict[str, Any]) -> SwapCardRequest: 

841 _require_type(payload, cls.type) 

842 return cls( 

843 join_code=payload["join_code"], 

844 player_token=payload["player_token"], 

845 own_index=int(payload["own_index"]), 

846 target_player_name=payload["target_player_name"], 

847 target_index=int(payload["target_index"]), 

848 ) 

849 

850 def to_dict(self) -> dict[str, Any]: 

851 return { 

852 "type": self.type, 

853 "join_code": self.join_code, 

854 "player_token": self.player_token, 

855 "own_index": self.own_index, 

856 "target_player_name": self.target_player_name, 

857 "target_index": self.target_index, 

858 } 

859 

860 

861@dataclass(frozen=True) 

862class SwapCardResponse: 

863 type: ClassVar[str] = "swap_card_response" 

864 game: GameSnapshot 

865 

866 @classmethod 

867 def from_dict(cls, payload: dict[str, Any]) -> SwapCardResponse: 

868 _require_type(payload, cls.type) 

869 return cls(game=GameSnapshot.from_dict(payload["game"])) 

870 

871 def to_dict(self) -> dict[str, Any]: 

872 return {"type": self.type, "game": self.game.to_dict()} 

873 

874 

875@dataclass(frozen=True) 

876class SkipBonusRequest: 

877 type: ClassVar[str] = "skip_bonus" 

878 join_code: str = "" 

879 player_token: str = "" 

880 

881 @classmethod 

882 def from_dict(cls, payload: dict[str, Any]) -> SkipBonusRequest: 

883 _require_type(payload, cls.type) 

884 return cls(join_code=payload["join_code"], player_token=payload["player_token"]) 

885 

886 def to_dict(self) -> dict[str, Any]: 

887 return {"type": self.type, "join_code": self.join_code, "player_token": self.player_token} 

888 

889 

890@dataclass(frozen=True) 

891class SkipBonusResponse: 

892 type: ClassVar[str] = "skip_bonus_response" 

893 game: GameSnapshot 

894 

895 @classmethod 

896 def from_dict(cls, payload: dict[str, Any]) -> SkipBonusResponse: 

897 _require_type(payload, cls.type) 

898 return cls(game=GameSnapshot.from_dict(payload["game"])) 

899 

900 def to_dict(self) -> dict[str, Any]: 

901 return {"type": self.type, "game": self.game.to_dict()} 

902 

903 

904@dataclass(frozen=True) 

905class SnapRequest: 

906 type: ClassVar[str] = "snap" 

907 join_code: str = "" 

908 player_token: str = "" 

909 hand_index: int = 0 

910 

911 @classmethod 

912 def from_dict(cls, payload: dict[str, Any]) -> SnapRequest: 

913 _require_type(payload, cls.type) 

914 return cls( 

915 join_code=payload["join_code"], 

916 player_token=payload["player_token"], 

917 hand_index=int(payload["hand_index"]), 

918 ) 

919 

920 def to_dict(self) -> dict[str, Any]: 

921 return { 

922 "type": self.type, "join_code": self.join_code, "player_token": self.player_token, 

923 "hand_index": self.hand_index, 

924 } 

925 

926 

927@dataclass(frozen=True) 

928class SnapResponse: 

929 type: ClassVar[str] = "snap_response" 

930 correct: bool 

931 game: GameSnapshot 

932 

933 @classmethod 

934 def from_dict(cls, payload: dict[str, Any]) -> SnapResponse: 

935 _require_type(payload, cls.type) 

936 return cls(correct=payload["correct"], game=GameSnapshot.from_dict(payload["game"])) 

937 

938 def to_dict(self) -> dict[str, Any]: 

939 return {"type": self.type, "correct": self.correct, "game": self.game.to_dict()} 

940 

941 

942@dataclass(frozen=True) 

943class BeaverRequest: 

944 """Player declares 'Beaver!' to trigger the last round.""" 

945 type: ClassVar[str] = "beaver" 

946 join_code: str = "" 

947 player_token: str = "" 

948 

949 @classmethod 

950 def from_dict(cls, payload: dict[str, Any]) -> BeaverRequest: 

951 _require_type(payload, cls.type) 

952 return cls(join_code=payload["join_code"], player_token=payload["player_token"]) 

953 

954 def to_dict(self) -> dict[str, Any]: 

955 return {"type": self.type, "join_code": self.join_code, "player_token": self.player_token} 

956 

957 

958@dataclass(frozen=True) 

959class BeaverResponse: 

960 type: ClassVar[str] = "beaver_response" 

961 game: GameSnapshot 

962 

963 @classmethod 

964 def from_dict(cls, payload: dict[str, Any]) -> BeaverResponse: 

965 _require_type(payload, cls.type) 

966 return cls(game=GameSnapshot.from_dict(payload["game"])) 

967 

968 def to_dict(self) -> dict[str, Any]: 

969 return {"type": self.type, "game": self.game.to_dict()} 

970 

971 

972@dataclass(frozen=True) 

973class KingBonusRequest: 

974 """Player executes the mandatory King bonus (draw again).""" 

975 type: ClassVar[str] = "king_bonus" 

976 join_code: str = "" 

977 player_token: str = "" 

978 

979 @classmethod 

980 def from_dict(cls, payload: dict[str, Any]) -> KingBonusRequest: 

981 _require_type(payload, cls.type) 

982 return cls(join_code=payload["join_code"], player_token=payload["player_token"]) 

983 

984 def to_dict(self) -> dict[str, Any]: 

985 return {"type": self.type, "join_code": self.join_code, "player_token": self.player_token} 

986 

987 

988@dataclass(frozen=True) 

989class KingBonusResponse: 

990 type: ClassVar[str] = "king_bonus_response" 

991 game: GameSnapshot 

992 

993 @classmethod 

994 def from_dict(cls, payload: dict[str, Any]) -> KingBonusResponse: 

995 _require_type(payload, cls.type) 

996 return cls(game=GameSnapshot.from_dict(payload["game"])) 

997 

998 def to_dict(self) -> dict[str, Any]: 

999 return {"type": self.type, "game": self.game.to_dict()}