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

124 statements  

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

1"""FastAPI HTTP transport layer for the Beaverbunch lobby server. 

2 

3Every endpoint simply converts the HTTP request body into the canonical 

4protocol dict, forwards it to ``SessionServer.handle()``, and returns the 

5resulting response dict as JSON. All real logic lives in 

6:mod:`beaverbunch.network.server` / :mod:`beaverbunch.network.session`. 

7""" 

8 

9from __future__ import annotations 

10 

11from typing import Any 

12 

13from fastapi import FastAPI, Request 

14from fastapi.middleware.cors import CORSMiddleware 

15from pydantic import BaseModel 

16 

17from beaverbunch import __version__ 

18from beaverbunch.network.config import RuntimeConfig, load_runtime_config 

19from beaverbunch.network.server import SessionServer 

20from beaverbunch.network.session import SessionManager 

21 

22 

23# --------------------------------------------------------------------------- 

24# App factory helpers 

25# --------------------------------------------------------------------------- 

26 

27def _get_server(request: Request) -> SessionServer: 

28 return request.app.state.session_server 

29 

30 

31def _dispatch(request: Request, message: dict[str, Any]) -> dict[str, Any]: 

32 return _get_server(request).handle(message) 

33 

34 

35def create_app(config: RuntimeConfig | None = None) -> FastAPI: 

36 runtime_config = config or load_runtime_config() 

37 application = FastAPI(title=runtime_config.app_name, version=runtime_config.app_version) 

38 application.state.runtime_config = runtime_config 

39 application.state.session_server = SessionServer( 

40 manager=SessionManager(max_sessions=runtime_config.max_sessions), 

41 ) 

42 

43 if runtime_config.cors_origins: 

44 application.add_middleware( 

45 CORSMiddleware, 

46 allow_origins=list(runtime_config.cors_origins), 

47 allow_methods=["*"], 

48 allow_headers=["*"], 

49 ) 

50 

51 # ----------------------------------------------------------------------- 

52 # Operational endpoints 

53 # ----------------------------------------------------------------------- 

54 

55 @application.get("/health", summary="Liveness probe") 

56 def health() -> dict[str, Any]: 

57 return { 

58 "status": "ok", 

59 "service": "beaverbunch-backend", 

60 "version": __version__, 

61 } 

62 

63 @application.get("/ready", summary="Readiness probe") 

64 def ready(request: Request) -> dict[str, Any]: 

65 server = _get_server(request) 

66 config = request.app.state.runtime_config 

67 return { 

68 "status": "ready", 

69 "sessions": { 

70 "active": server.manager.session_count, 

71 "capacity": config.max_sessions, 

72 }, 

73 } 

74 

75 # ----------------------------------------------------------------------- 

76 # Lobby endpoints 

77 # ----------------------------------------------------------------------- 

78 

79 @application.post("/sessions", summary="Create a new lobby session", status_code=201) 

80 def create_session(request: Request, body: CreateSessionBody) -> dict[str, Any]: 

81 message: dict[str, Any] = {"type": "create_session", "host_name": body.host_name} 

82 if body.settings is not None: 

83 message["settings"] = body.settings 

84 return _dispatch(request, message) 

85 

86 @application.post("/sessions/{join_code}/join", summary="Join an existing session") 

87 def join_session(request: Request, join_code: str, body: JoinSessionBody) -> dict[str, Any]: 

88 return _dispatch( 

89 request, 

90 { 

91 "type": "join_session", 

92 "join_code": join_code, 

93 "player_name": body.player_name, 

94 }, 

95 ) 

96 

97 @application.post("/sessions/{join_code}/start", summary="Start the game (host only)") 

98 def start_session(request: Request, join_code: str, body: StartSessionBody) -> dict[str, Any]: 

99 return _dispatch( 

100 request, 

101 { 

102 "type": "start_session", 

103 "join_code": join_code, 

104 "player_token": body.player_token, 

105 }, 

106 ) 

107 

108 @application.get("/sessions/{join_code}", summary="Fetch current session snapshot") 

109 def get_session(request: Request, join_code: str) -> dict[str, Any]: 

110 return _dispatch( 

111 request, 

112 { 

113 "type": "get_session", 

114 "join_code": join_code, 

115 }, 

116 ) 

117 

118 @application.post("/sessions/{join_code}/leave", summary="Leave a session") 

119 def leave_session(request: Request, join_code: str, body: LeaveSessionBody) -> dict[str, Any]: 

120 return _dispatch( 

121 request, 

122 { 

123 "type": "leave_session", 

124 "join_code": join_code, 

125 "player_token": body.player_token, 

126 }, 

127 ) 

128 

129 @application.post("/sessions/{join_code}/close", summary="Close a session (host only)") 

130 def close_session(request: Request, join_code: str, body: CloseSessionBody) -> dict[str, Any]: 

131 return _dispatch( 

132 request, 

133 { 

134 "type": "close_session", 

135 "join_code": join_code, 

136 "player_token": body.player_token, 

137 }, 

138 ) 

139 

140 # ----------------------------------------------------------------------- 

141 # Game-phase endpoints 

142 # ----------------------------------------------------------------------- 

143 

144 @application.get("/sessions/{join_code}/game", summary="Get current game state (perspective-aware)") 

145 def get_game(request: Request, join_code: str, player_token: str) -> dict[str, Any]: 

146 return _dispatch( 

147 request, 

148 { 

149 "type": "get_game", 

150 "join_code": join_code, 

151 "player_token": player_token, 

152 }, 

153 ) 

154 

155 @application.post("/sessions/{join_code}/game/peek-initial", summary="Peek at initial cards (game start)") 

156 def peek_initial(request: Request, join_code: str, body: PeekInitialBody) -> dict[str, Any]: 

157 return _dispatch( 

158 request, 

159 { 

160 "type": "peek_initial", 

161 "join_code": join_code, 

162 "player_token": body.player_token, 

163 "indices": body.indices, 

164 }, 

165 ) 

166 

167 @application.post("/sessions/{join_code}/game/draw", summary="Draw a card from the deck") 

168 def draw_card(request: Request, join_code: str, body: DrawCardBody) -> dict[str, Any]: 

169 return _dispatch( 

170 request, 

171 { 

172 "type": "draw_card", 

173 "join_code": join_code, 

174 "player_token": body.player_token, 

175 }, 

176 ) 

177 

178 @application.post("/sessions/{join_code}/game/draw-discard", summary="Draw the top card from the discard pile") 

179 def draw_discard(request: Request, join_code: str, body: DrawDiscardBody) -> dict[str, Any]: 

180 return _dispatch( 

181 request, 

182 { 

183 "type": "draw_discard", 

184 "join_code": join_code, 

185 "player_token": body.player_token, 

186 }, 

187 ) 

188 

189 @application.post("/sessions/{join_code}/game/keep", summary="Keep drawn card, replacing a hand card") 

190 def keep_card(request: Request, join_code: str, body: KeepCardBody) -> dict[str, Any]: 

191 return _dispatch( 

192 request, 

193 { 

194 "type": "keep_card", 

195 "join_code": join_code, 

196 "player_token": body.player_token, 

197 "hand_index": body.hand_index, 

198 }, 

199 ) 

200 

201 @application.post("/sessions/{join_code}/game/discard", summary="Discard the drawn card") 

202 def discard_drawn(request: Request, join_code: str, body: DiscardDrawnBody) -> dict[str, Any]: 

203 return _dispatch( 

204 request, 

205 { 

206 "type": "discard_drawn", 

207 "join_code": join_code, 

208 "player_token": body.player_token, 

209 }, 

210 ) 

211 

212 @application.post("/sessions/{join_code}/game/peek", summary="Jack bonus: peek at own card") 

213 def peek_own(request: Request, join_code: str, body: PeekOwnBody) -> dict[str, Any]: 

214 return _dispatch( 

215 request, 

216 { 

217 "type": "peek_own", 

218 "join_code": join_code, 

219 "player_token": body.player_token, 

220 "hand_index": body.hand_index, 

221 }, 

222 ) 

223 

224 @application.post( 

225 "/sessions/{join_code}/game/swap", summary="Queen bonus: swap own card with another player's card", 

226 ) 

227 def swap_card(request: Request, join_code: str, body: SwapCardBody) -> dict[str, Any]: 

228 return _dispatch( 

229 request, 

230 { 

231 "type": "swap_card", 

232 "join_code": join_code, 

233 "player_token": body.player_token, 

234 "own_index": body.own_index, 

235 "target_player_name": body.target_player_name, 

236 "target_index": body.target_index, 

237 }, 

238 ) 

239 

240 @application.post("/sessions/{join_code}/game/skip-bonus", summary="Skip the current bonus action") 

241 def skip_bonus(request: Request, join_code: str, body: SkipBonusBody) -> dict[str, Any]: 

242 return _dispatch( 

243 request, 

244 { 

245 "type": "skip_bonus", 

246 "join_code": join_code, 

247 "player_token": body.player_token, 

248 }, 

249 ) 

250 

251 @application.post("/sessions/{join_code}/game/snap", summary="Snap: claim a hand card matches the top discard") 

252 def snap(request: Request, join_code: str, body: SnapBody) -> dict[str, Any]: 

253 return _dispatch( 

254 request, 

255 { 

256 "type": "snap", 

257 "join_code": join_code, 

258 "player_token": body.player_token, 

259 "hand_index": body.hand_index, 

260 }, 

261 ) 

262 

263 @application.post("/sessions/{join_code}/game/beaver", summary="Declare Beaver to trigger the last round") 

264 def beaver(request: Request, join_code: str, body: BeaverBody) -> dict[str, Any]: 

265 return _dispatch( 

266 request, 

267 { 

268 "type": "beaver", 

269 "join_code": join_code, 

270 "player_token": body.player_token, 

271 }, 

272 ) 

273 

274 return application 

275 

276 

277# --------------------------------------------------------------------------- 

278# Pydantic request bodies 

279# --------------------------------------------------------------------------- 

280 

281class CreateSessionBody(BaseModel): 

282 host_name: str | None = None 

283 settings: dict[str, Any] | None = None 

284 

285 

286class JoinSessionBody(BaseModel): 

287 player_name: str 

288 

289 

290class StartSessionBody(BaseModel): 

291 player_token: str 

292 

293 

294class LeaveSessionBody(BaseModel): 

295 player_token: str 

296 

297 

298class CloseSessionBody(BaseModel): 

299 player_token: str 

300 

301 

302class GetGameBody(BaseModel): 

303 player_token: str 

304 

305 

306class PeekInitialBody(BaseModel): 

307 player_token: str 

308 indices: list[int] 

309 

310 

311class DrawCardBody(BaseModel): 

312 player_token: str 

313 

314 

315class DrawDiscardBody(BaseModel): 

316 player_token: str 

317 

318 

319class KeepCardBody(BaseModel): 

320 player_token: str 

321 hand_index: int 

322 

323 

324class DiscardDrawnBody(BaseModel): 

325 player_token: str 

326 

327 

328class PeekOwnBody(BaseModel): 

329 player_token: str 

330 hand_index: int 

331 

332 

333class SwapCardBody(BaseModel): 

334 player_token: str 

335 own_index: int 

336 target_player_name: str 

337 target_index: int 

338 

339 

340class SkipBonusBody(BaseModel): 

341 player_token: str 

342 

343 

344class SnapBody(BaseModel): 

345 player_token: str 

346 hand_index: int 

347 

348 

349class BeaverBody(BaseModel): 

350 player_token: str 

351 

352 

353app = create_app()