diff --git a/apps/api/package.json b/apps/api/package.json index 60fd85a..bfd2878 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -14,12 +14,14 @@ "@lila/shared": "workspace:*", "better-auth": "^1.6.2", "cors": "^2.8.6", - "express": "^5.2.1" + "express": "^5.2.1", + "ws": "^8.20.0" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/supertest": "^7.2.0", + "@types/ws": "^8.18.1", "supertest": "^7.2.2", "tsx": "^4.21.0" } diff --git a/apps/api/src/lobbyGameStore/InMemoryLobbyGameStore.ts b/apps/api/src/lobbyGameStore/InMemoryLobbyGameStore.ts new file mode 100644 index 0000000..7accbb9 --- /dev/null +++ b/apps/api/src/lobbyGameStore/InMemoryLobbyGameStore.ts @@ -0,0 +1,24 @@ +import type { LobbyGameStore, LobbyGameData } from "./LobbyGameStore.js"; + +export class InMemoryLobbyGameStore implements LobbyGameStore { + private games = new Map(); + + async create(lobbyId: string, data: LobbyGameData): Promise { + if (this.games.has(lobbyId)) { + throw new Error(`Game already exists for lobby: ${lobbyId}`); + } + this.games.set(lobbyId, data); + } + + async get(lobbyId: string): Promise { + return this.games.get(lobbyId) ?? null; + } + + async set(lobbyId: string, data: LobbyGameData): Promise { + this.games.set(lobbyId, data); + } + + async delete(lobbyId: string): Promise { + this.games.delete(lobbyId); + } +} diff --git a/apps/api/src/lobbyGameStore/LobbyGameStore.ts b/apps/api/src/lobbyGameStore/LobbyGameStore.ts new file mode 100644 index 0000000..4bfdbdd --- /dev/null +++ b/apps/api/src/lobbyGameStore/LobbyGameStore.ts @@ -0,0 +1,17 @@ +import type { MultiplayerQuestion } from "../services/multiplayerGameService.js"; + +export type LobbyGameData = { + questions: MultiplayerQuestion[]; + currentIndex: number; + // NOTE: Map types are used here for O(1) lookups in-process. + // When migrating to Valkey, convert to plain objects for JSON serialization. + playerAnswers: Map; // userId → selectedOptionId, null = timed out + scores: Map; // userId → running total +}; + +export interface LobbyGameStore { + create(lobbyId: string, data: LobbyGameData): Promise; + get(lobbyId: string): Promise; + set(lobbyId: string, data: LobbyGameData): Promise; + delete(lobbyId: string): Promise; +} diff --git a/apps/api/src/lobbyGameStore/index.ts b/apps/api/src/lobbyGameStore/index.ts new file mode 100644 index 0000000..67dc9a2 --- /dev/null +++ b/apps/api/src/lobbyGameStore/index.ts @@ -0,0 +1,2 @@ +export type { LobbyGameStore, LobbyGameData } from "./LobbyGameStore.js"; +export { InMemoryLobbyGameStore } from "./InMemoryLobbyGameStore.js"; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 86d05ed..c2b6d34 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,8 +1,13 @@ +import { createServer } from "http"; import { createApp } from "./app.js"; +import { setupWebSocket } from "./ws/index.js"; const PORT = Number(process.env["PORT"] ?? 3000); const app = createApp(); +const server = createServer(app); + +setupWebSocket(server); app.listen(PORT, () => { console.log(`Server listening on port ${PORT}`); diff --git a/apps/api/src/services/multiplayerGameService.ts b/apps/api/src/services/multiplayerGameService.ts new file mode 100644 index 0000000..32727b1 --- /dev/null +++ b/apps/api/src/services/multiplayerGameService.ts @@ -0,0 +1,75 @@ +import { randomUUID } from "crypto"; +import { getGameTerms, getDistractors } from "@lila/db"; +import type { + GameQuestion, + AnswerOption, + SupportedLanguageCode, + SupportedPos, + DifficultyLevel, +} from "@lila/shared"; + +// TODO(game-mode-slice): replace with lobby settings when mode selection lands +const MULTIPLAYER_DEFAULTS = { + sourceLanguage: "en" as SupportedLanguageCode, + targetLanguage: "it" as SupportedLanguageCode, + pos: "noun" as SupportedPos, + difficulty: "easy" as DifficultyLevel, + rounds: 3, +}; + +const shuffle = (array: T[]): T[] => { + const result = [...array]; + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const temp = result[i]!; + result[i] = result[j]!; + result[j] = temp; + } + return result; +}; + +export type MultiplayerQuestion = GameQuestion & { correctOptionId: number }; + +export const generateMultiplayerQuestions = async (): Promise< + MultiplayerQuestion[] +> => { + const correctAnswers = await getGameTerms( + MULTIPLAYER_DEFAULTS.sourceLanguage, + MULTIPLAYER_DEFAULTS.targetLanguage, + MULTIPLAYER_DEFAULTS.pos, + MULTIPLAYER_DEFAULTS.difficulty, + MULTIPLAYER_DEFAULTS.rounds, + ); + + const questions: MultiplayerQuestion[] = await Promise.all( + correctAnswers.map(async (correctAnswer) => { + const distractorTexts = await getDistractors( + correctAnswer.termId, + correctAnswer.targetText, + MULTIPLAYER_DEFAULTS.targetLanguage, + MULTIPLAYER_DEFAULTS.pos, + MULTIPLAYER_DEFAULTS.difficulty, + 3, + ); + + const optionTexts = [correctAnswer.targetText, ...distractorTexts]; + const shuffledTexts = shuffle(optionTexts); + const correctOptionId = shuffledTexts.indexOf(correctAnswer.targetText); + + const options: AnswerOption[] = shuffledTexts.map((text, index) => ({ + optionId: index, + text, + })); + + return { + questionId: randomUUID(), + prompt: correctAnswer.sourceText, + gloss: correctAnswer.sourceGloss, + options, + correctOptionId, + }; + }), + ); + + return questions; +}; diff --git a/apps/api/src/types/express.d.ts b/apps/api/src/types/express.d.ts index 914a2fd..5f2be8d 100644 --- a/apps/api/src/types/express.d.ts +++ b/apps/api/src/types/express.d.ts @@ -1,4 +1,5 @@ import type { Session, User } from "better-auth"; +import type { WebSocket } from "ws"; declare global { namespace Express { @@ -8,4 +9,10 @@ declare global { } } +declare module "ws" { + interface WebSocket { + lobbyId?: string | undefined; + } +} + export {}; diff --git a/apps/api/src/ws/auth.ts b/apps/api/src/ws/auth.ts new file mode 100644 index 0000000..75c3613 --- /dev/null +++ b/apps/api/src/ws/auth.ts @@ -0,0 +1,32 @@ +import type { IncomingMessage } from "http"; +import type { Duplex } from "stream"; +import type { WebSocketServer, WebSocket } from "ws"; +import { fromNodeHeaders } from "better-auth/node"; +import { auth } from "../lib/auth.js"; + +export const handleUpgrade = async ( + request: IncomingMessage, + socket: Duplex, + head: Buffer, + wss: WebSocketServer, +): Promise => { + try { + const session = await auth.api.getSession({ + headers: fromNodeHeaders(request.headers), + }); + + if (!session) { + socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); + socket.destroy(); + return; + } + + wss.handleUpgrade(request, socket, head, (ws: WebSocket) => { + wss.emit("connection", ws, request, session); + }); + } catch (err) { + console.error("WebSocket auth error:", err); + socket.write("HTTP/1.1 500 Internal Server Error\r\n\r\n"); + socket.destroy(); + } +}; diff --git a/apps/api/src/ws/connections.ts b/apps/api/src/ws/connections.ts new file mode 100644 index 0000000..e97e2e7 --- /dev/null +++ b/apps/api/src/ws/connections.ts @@ -0,0 +1,44 @@ +import type { WebSocket } from "ws"; + +// Map> +const connections = new Map>(); + +export const addConnection = ( + lobbyId: string, + userId: string, + ws: WebSocket, +): void => { + if (!connections.has(lobbyId)) { + connections.set(lobbyId, new Map()); + } + connections.get(lobbyId)!.set(userId, ws); +}; + +export const removeConnection = (lobbyId: string, userId: string): void => { + const lobby = connections.get(lobbyId); + if (!lobby) return; + lobby.delete(userId); + if (lobby.size === 0) { + connections.delete(lobbyId); + } +}; + +export const getConnections = (lobbyId: string): Map => { + return connections.get(lobbyId) ?? new Map(); +}; + +export const broadcastToLobby = ( + lobbyId: string, + message: unknown, + excludeUserId?: string, +): void => { + const lobby = connections.get(lobbyId); + if (!lobby) return; + const payload = JSON.stringify(message); + for (const [userId, ws] of lobby) { + if (excludeUserId && userId === excludeUserId) continue; + if (ws.readyState === ws.OPEN) { + ws.send(payload); + } + } +}; diff --git a/apps/api/src/ws/handlers/lobbyHandlers.ts b/apps/api/src/ws/handlers/lobbyHandlers.ts new file mode 100644 index 0000000..88f2226 --- /dev/null +++ b/apps/api/src/ws/handlers/lobbyHandlers.ts @@ -0,0 +1,73 @@ +import type { WebSocket } from "ws"; +import type { User } from "better-auth"; +import type { WsLobbyJoin, WsLobbyLeave } from "@lila/shared"; +import { getLobbyByCodeWithPlayers, deleteLobby, removePlayer } from "@lila/db"; +import { + addConnection, + removeConnection, + broadcastToLobby, +} from "../connections.js"; +import { NotFoundError, ConflictError } from "../../errors/AppError.js"; + +export const handleLobbyJoin = async ( + ws: WebSocket, + msg: WsLobbyJoin, + user: User, +): Promise => { + // Load lobby and validate membership + const lobby = await getLobbyByCodeWithPlayers(msg.code); + if (!lobby) { + throw new NotFoundError("Lobby not found"); + } + + if (lobby.status !== "waiting") { + throw new ConflictError("Lobby is not in waiting state"); + } + + if (!lobby.players.some((p) => p.userId === user.id)) { + throw new ConflictError("You are not a member of this lobby"); + } + + // Register connection and tag the socket with lobbyId + addConnection(lobby.id, user.id, ws); + ws.lobbyId = lobby.id; + + // Broadcast updated lobby state to all players + broadcastToLobby(lobby.id, { type: "lobby:state", lobby }); +}; + +export const handleLobbyLeave = async ( + ws: WebSocket, + msg: WsLobbyLeave, + user: User, +): Promise => { + const lobby = await getLobbyByCodeWithPlayers(msg.lobbyId); + if (!lobby) return; + + removeConnection(msg.lobbyId, user.id); + ws.lobbyId = undefined; + + if (lobby.hostUserId === user.id) { + await deleteLobby(msg.lobbyId); + broadcastToLobby(msg.lobbyId, { + type: "error", + code: "LOBBY_CLOSED", + message: "Host left the lobby", + }); + for (const player of lobby.players) { + removeConnection(msg.lobbyId, player.userId); + } + } else { + await removePlayer(msg.lobbyId, user.id); + const updated = await getLobbyByCodeWithPlayers(lobby.code); + if (!updated) return; + broadcastToLobby(msg.lobbyId, { type: "lobby:state", lobby: updated }); + + // TODO(reconnection-slice): if lobby.status === 'in_progress', the game + // continues with remaining players. If only one player remains after this + // leave, end the game immediately and declare them winner. Currently we + // broadcast updated lobby state and let the game resolve naturally via + // timeouts — the disconnected player's answers will be null each round. + // When reconnection handling is added, this is the place to change. + } +}; diff --git a/apps/api/src/ws/index.ts b/apps/api/src/ws/index.ts new file mode 100644 index 0000000..4540ce0 --- /dev/null +++ b/apps/api/src/ws/index.ts @@ -0,0 +1,52 @@ +import { WebSocketServer } from "ws"; +import type { WebSocket } from "ws"; +import type { Server } from "http"; +import type { IncomingMessage } from "http"; +import { handleUpgrade } from "./auth.js"; +import { handleMessage, type AuthenticatedUser } from "./router.js"; +import { removeConnection } from "./connections.js"; +import { handleLobbyLeave } from "./handlers/lobbyHandlers.js"; + +export const setupWebSocket = (server: Server): WebSocketServer => { + const wss = new WebSocketServer({ noServer: true }); + + server.on("upgrade", (request, socket, head) => { + if (request.url !== "/ws") { + socket.destroy(); + return; + } + handleUpgrade(request, socket, head, wss); + }); + + wss.on( + "connection", + (ws: WebSocket, _request: IncomingMessage, auth: AuthenticatedUser) => { + ws.on("message", (rawData) => { + handleMessage(ws, rawData, auth); + }); + + ws.on("close", () => { + handleDisconnect(ws, auth); + }); + + ws.on("error", (err) => { + console.error(`WebSocket error for user ${auth.user.id}:`, err); + }); + }, + ); + + return wss; +}; + +const handleDisconnect = async ( + ws: WebSocket, + auth: AuthenticatedUser, +): Promise => { + if (!ws.lobbyId) return; // user connected but never joined a lobby + removeConnection(ws.lobbyId, auth.user.id); + await handleLobbyLeave( + ws, + { type: "lobby:leave", lobbyId: ws.lobbyId }, + auth.user, + ); +}; diff --git a/apps/api/src/ws/router.ts b/apps/api/src/ws/router.ts new file mode 100644 index 0000000..1a1dad4 --- /dev/null +++ b/apps/api/src/ws/router.ts @@ -0,0 +1,74 @@ +import type { WebSocket } from "ws"; +import type { Session, User } from "better-auth"; +import { WsClientMessageSchema } from "@lila/shared"; +import { + handleLobbyJoin, + handleLobbyLeave, + handleLobbyStart, +} from "./handlers/lobbyHandlers.js"; +import { handleGameAnswer } from "./handlers/gameHandlers.js"; +import { AppError } from "../errors/AppError.js"; + +export type AuthenticatedUser = { session: Session; user: User }; + +const sendError = (ws: WebSocket, code: string, message: string): void => { + ws.send(JSON.stringify({ type: "error", code, message })); +}; + +const assertExhaustive = (_: never): never => { + throw new Error("Unhandled message type"); +}; + +export const handleMessage = async ( + ws: WebSocket, + rawData: unknown, + auth: AuthenticatedUser, +): Promise => { + // Layer 1: parse and validate incoming message + let parsed: unknown; + try { + parsed = JSON.parse( + typeof rawData === "string" ? rawData : (rawData as Buffer).toString(), + ); + } catch { + ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" })); + return; + } + const result = WsClientMessageSchema.safeParse(parsed); + + if (!result.success) { + ws.send( + JSON.stringify({ type: "error", message: "Invalid message format" }), + ); + return; + } + + const msg = result.data; + + // Layer 2: dispatch to handler, catch and translate errors + try { + switch (msg.type) { + case "lobby:join": + await handleLobbyJoin(ws, msg, auth.user); + break; + case "lobby:leave": + await handleLobbyLeave(ws, msg, auth.user); + break; + case "lobby:start": + await handleLobbyStart(ws, msg, auth.user); + break; + case "game:answer": + await handleGameAnswer(ws, msg, auth.user); + break; + default: + assertExhaustive(msg); + } + } catch (err) { + if (err instanceof AppError) { + sendError(ws, err.name, err.message); + } else { + console.error("Unhandled WS error:", err); + sendError(ws, "INTERNAL_ERROR", "An unexpected error occurred"); + } + } +}; diff --git a/packages/shared/src/schemas/lobby.ts b/packages/shared/src/schemas/lobby.ts index 1e92a6f..b2e3c55 100644 --- a/packages/shared/src/schemas/lobby.ts +++ b/packages/shared/src/schemas/lobby.ts @@ -112,11 +112,19 @@ export const WsGameFinishedSchema = z.object({ export type WsGameFinished = z.infer; +export const WsErrorSchema = z.object({ + type: z.literal("error"), + code: z.string(), + message: z.string(), +}); +export type WsError = z.infer; + export const WsServerMessageSchema = z.discriminatedUnion("type", [ WsLobbyStateSchema, WsGameQuestionSchema, WsGameAnswerResultSchema, WsGameFinishedSchema, + WsErrorSchema, ]); export type WsServerMessage = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11520d7..eaea759 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: express: specifier: ^5.2.1 version: 5.2.1 + ws: + specifier: ^8.20.0 + version: 8.20.0 devDependencies: '@types/cors': specifier: ^2.8.19 @@ -72,6 +75,9 @@ importers: '@types/supertest': specifier: ^7.2.0 version: 7.2.0 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 supertest: specifier: ^7.2.2 version: 7.2.2 @@ -1311,6 +1317,9 @@ packages: '@types/supertest@7.2.0': resolution: {integrity: sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@typescript-eslint/eslint-plugin@8.57.1': resolution: {integrity: sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2983,6 +2992,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xlsx@0.18.5: resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} engines: {node: '>=0.8'} @@ -3915,6 +3936,10 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.12.0 + '@typescript-eslint/eslint-plugin@8.57.1(@typescript-eslint/parser@8.57.1(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.3(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -5586,6 +5611,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.20.0: {} + xlsx@0.18.5: dependencies: adler-32: 1.3.1