diff --git a/apps/api/src/controllers/lobbyController.ts b/apps/api/src/controllers/lobbyController.ts new file mode 100644 index 0000000..113c8c5 --- /dev/null +++ b/apps/api/src/controllers/lobbyController.ts @@ -0,0 +1,37 @@ +import type { Request, Response, NextFunction } from "express"; +import { createLobby, joinLobby } from "../services/lobbyService.js"; + +export const createLobbyHandler = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const userId = req.session!.user.id; + const lobby = await createLobby(userId); + res.json({ success: true, data: lobby }); + } catch (error) { + next(error); + } +}; + +export const joinLobbyHandler = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const userId = req.session!.user.id; + const code = req.params["code"]; + if (!code) { + return next(new Error("Missing code param")); + } + if (typeof code !== "string") { + return next(new Error("Missing or invalid code param")); + } + const lobby = await joinLobby(code, userId); + res.json({ success: true, data: lobby }); + } catch (error) { + next(error); + } +}; diff --git a/apps/api/src/routes/apiRouter.ts b/apps/api/src/routes/apiRouter.ts index 6ad84eb..f5ebd01 100644 --- a/apps/api/src/routes/apiRouter.ts +++ b/apps/api/src/routes/apiRouter.ts @@ -2,8 +2,10 @@ import express from "express"; import { Router } from "express"; import { healthRouter } from "./healthRouter.js"; import { gameRouter } from "./gameRouter.js"; +import { lobbyRouter } from "./lobbyRouter.js"; export const apiRouter: Router = express.Router(); apiRouter.use("/health", healthRouter); apiRouter.use("/game", gameRouter); +apiRouter.use("/lobbies", lobbyRouter); diff --git a/apps/api/src/routes/lobbyRouter.ts b/apps/api/src/routes/lobbyRouter.ts new file mode 100644 index 0000000..5bd82dd --- /dev/null +++ b/apps/api/src/routes/lobbyRouter.ts @@ -0,0 +1,14 @@ +import express from "express"; +import type { Router } from "express"; +import { + createLobbyHandler, + joinLobbyHandler, +} from "../controllers/lobbyController.js"; +import { requireAuth } from "../middleware/authMiddleware.js"; + +export const lobbyRouter: Router = express.Router(); + +lobbyRouter.use(requireAuth); + +lobbyRouter.post("/", createLobbyHandler); +lobbyRouter.post("/:code/join", joinLobbyHandler); diff --git a/apps/api/src/services/lobbyService.ts b/apps/api/src/services/lobbyService.ts new file mode 100644 index 0000000..3e307ef --- /dev/null +++ b/apps/api/src/services/lobbyService.ts @@ -0,0 +1,70 @@ +import { randomInt } from "crypto"; +import { + createLobby as createLobbyModel, + getLobbyByCodeWithPlayers, + addPlayer, +} from "@lila/db"; +import type { Lobby, LobbyWithPlayers } from "@lila/db"; +import { MAX_LOBBY_PLAYERS } from "@lila/shared"; +import { NotFoundError, ConflictError, AppError } from "../errors/AppError.js"; + +const CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // Crockford Base32 +const CODE_LENGTH = 6; +const MAX_CODE_ATTEMPTS = 5; + +const generateLobbyCode = (): string => { + let code = ""; + for (let i = 0; i < CODE_LENGTH; i++) { + code += CODE_ALPHABET[randomInt(CODE_ALPHABET.length)]; + } + return code; +}; + +const isUniqueViolation = (err: unknown): boolean => { + return (err as { code?: string })?.code === "23505"; +}; + +export const createLobby = async (hostUserId: string): Promise => { + for (let i = 0; i < MAX_CODE_ATTEMPTS; i++) { + const code = generateLobbyCode(); + try { + return await createLobbyModel(code, hostUserId); + } catch (err) { + if (isUniqueViolation(err)) continue; + throw err; + } + } + throw new AppError("Could not generate a unique lobby code", 500); +}; + +export const joinLobby = async ( + code: string, + userId: string, +): Promise => { + const lobby = await getLobbyByCodeWithPlayers(code); + if (!lobby) { + throw new NotFoundError(`Lobby not found: ${code}`); + } + if (lobby.status !== "waiting") { + throw new ConflictError("Game has already started"); + } + if (lobby.players.some((p) => p.userId === userId)) { + return lobby; // idempotent: already in lobby + } + if (lobby.players.length >= MAX_LOBBY_PLAYERS) { + throw new ConflictError("Lobby is full"); + } + + const player = await addPlayer(lobby.id, userId, MAX_LOBBY_PLAYERS); + if (!player) { + // Race fallback: another request filled the last slot, started the game, + // or the user joined concurrently. Pre-checks above handle the common cases. + throw new ConflictError("Lobby is no longer available"); + } + + const fresh = await getLobbyByCodeWithPlayers(code); + if (!fresh) { + throw new AppError("Lobby disappeared during join", 500); + } + return fresh; +};