From 7f56ad89e62c13a51244b8d13412f05ac9ab6e9f Mon Sep 17 00:00:00 2001 From: lila Date: Fri, 17 Apr 2026 15:50:08 +0200 Subject: [PATCH] feat(api): add WebSocket handlers and game state management - handleLobbyJoin: validates DB membership and waiting status, registers connection, tags ws.lobbyId, broadcasts lobby:state - handleLobbyLeave: host leave deletes lobby, non-host leave removes player and broadcasts updated state - handleLobbyStart: validates host + connected players >= 2, generates questions, initializes LobbyGameData, broadcasts first game:question, starts 15s round timer - handleGameAnswer: stores answer, resolves round when all players answered or timer fires - resolveRound: evaluates answers, updates scores, broadcasts game:answer_result, advances to next question or ends game - endGame: persists final scores via finishGame transaction, determines winnerIds handling ties, broadcasts game:finished - gameState.ts: shared lobbyGameStore singleton and timers Map - LobbyGameData extended with code field to avoid mid-game DB lookups by ID --- apps/api/src/lobbyGameStore/LobbyGameStore.ts | 1 + apps/api/src/ws/gameState.ts | 4 + apps/api/src/ws/handlers/gameHandlers.ts | 185 ++++++++++++++++++ apps/api/src/ws/handlers/lobbyHandlers.ts | 86 +++++++- 4 files changed, 274 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/ws/gameState.ts create mode 100644 apps/api/src/ws/handlers/gameHandlers.ts diff --git a/apps/api/src/lobbyGameStore/LobbyGameStore.ts b/apps/api/src/lobbyGameStore/LobbyGameStore.ts index 4bfdbdd..bf59c3b 100644 --- a/apps/api/src/lobbyGameStore/LobbyGameStore.ts +++ b/apps/api/src/lobbyGameStore/LobbyGameStore.ts @@ -1,6 +1,7 @@ import type { MultiplayerQuestion } from "../services/multiplayerGameService.js"; export type LobbyGameData = { + code: string; questions: MultiplayerQuestion[]; currentIndex: number; // NOTE: Map types are used here for O(1) lookups in-process. diff --git a/apps/api/src/ws/gameState.ts b/apps/api/src/ws/gameState.ts new file mode 100644 index 0000000..3591843 --- /dev/null +++ b/apps/api/src/ws/gameState.ts @@ -0,0 +1,4 @@ +import { InMemoryLobbyGameStore } from "../lobbyGameStore/index.js"; + +export const lobbyGameStore = new InMemoryLobbyGameStore(); +export const timers = new Map(); diff --git a/apps/api/src/ws/handlers/gameHandlers.ts b/apps/api/src/ws/handlers/gameHandlers.ts new file mode 100644 index 0000000..49a92ae --- /dev/null +++ b/apps/api/src/ws/handlers/gameHandlers.ts @@ -0,0 +1,185 @@ +import type { WebSocket } from "ws"; +import type { User } from "better-auth"; +import type { WsGameAnswer } from "@lila/shared"; +import { finishGame, getLobbyByCodeWithPlayers } from "@lila/db"; +import { broadcastToLobby, getConnections } from "../connections.js"; +import { lobbyGameStore, timers } from "../gameState.js"; +import { NotFoundError, ConflictError } from "../../errors/AppError.js"; + +export const handleGameAnswer = async ( + _ws: WebSocket, + msg: WsGameAnswer, + user: User, +): Promise => { + const state = await lobbyGameStore.get(msg.lobbyId); + if (!state) { + throw new NotFoundError("Game not found"); + } + + const currentQuestion = state.questions[state.currentIndex]; + if (!currentQuestion) { + throw new ConflictError("No active question"); + } + + // Reject stale answers + if (currentQuestion.questionId !== msg.questionId) { + throw new ConflictError("Answer is for wrong question"); + } + + // Reject duplicate answers + if (state.playerAnswers.has(user.id)) { + throw new ConflictError("Already answered this question"); + } + + // Store answer + state.playerAnswers.set(user.id, msg.selectedOptionId); + await lobbyGameStore.set(msg.lobbyId, state); + + // Check if all connected players have answered + const connected = getConnections(msg.lobbyId); + const allAnswered = [...connected.keys()].every((userId) => + state.playerAnswers.has(userId), + ); + + if (allAnswered) { + // Clear timer — no need to wait + const timer = timers.get(msg.lobbyId); + if (timer) { + clearTimeout(timer); + timers.delete(msg.lobbyId); + } + await resolveRound(msg.lobbyId, state.currentIndex, state.questions.length); + } +}; + +export const resolveRound = async ( + lobbyId: string, + questionIndex: number, + totalQuestions: number, +): Promise => { + const state = await lobbyGameStore.get(lobbyId); + if (!state) return; // lobby was deleted mid-round, nothing to do + + const currentQuestion = state.questions[questionIndex]; + if (!currentQuestion) return; + + // Fill null for any players who didn't answer (timed out) + const connected = getConnections(lobbyId); + for (const userId of connected.keys()) { + if (!state.playerAnswers.has(userId)) { + state.playerAnswers.set(userId, null); + } + } + + // Evaluate answers and update scores + const results: { + userId: string; + selectedOptionId: number | null; + isCorrect: boolean; + }[] = []; + + for (const [userId, selectedOptionId] of state.playerAnswers) { + const isCorrect = + selectedOptionId !== null && + selectedOptionId === currentQuestion.correctOptionId; + if (isCorrect) { + state.scores.set(userId, (state.scores.get(userId) ?? 0) + 1); + } + results.push({ userId, selectedOptionId, isCorrect }); + } + + // Build updated players array for broadcast + const players = [...state.scores.entries()].map(([userId, score]) => ({ + userId, + score, + lobbyId, + user: { id: userId, name: userId }, // name resolved below + })); + + // Resolve user names from DB + const lobby = await getLobbyByCodeWithPlayers(state.code); + + const namedPlayers = players.map((p) => { + const dbPlayer = lobby?.players.find((dp) => dp.userId === p.userId); + return { + ...p, + user: { id: p.userId, name: dbPlayer?.user.name ?? p.userId }, + }; + }); + + // Broadcast answer result + broadcastToLobby(lobbyId, { + type: "game:answer_result", + correctOptionId: currentQuestion.correctOptionId, + results, + players: namedPlayers, + }); + + // Save updated state + state.playerAnswers = new Map(); + state.currentIndex = questionIndex + 1; + await lobbyGameStore.set(lobbyId, state); + + const isLastRound = questionIndex + 1 >= totalQuestions; + + if (isLastRound) { + await endGame(lobbyId, state); + } else { + // Wait 3s then broadcast next question + setTimeout(async () => { + const fresh = await lobbyGameStore.get(lobbyId); + if (!fresh) return; + const nextQuestion = fresh.questions[fresh.currentIndex]; + if (!nextQuestion) return; + broadcastToLobby(lobbyId, { + type: "game:question", + question: { + questionId: nextQuestion.questionId, + prompt: nextQuestion.prompt, + gloss: nextQuestion.gloss, + options: nextQuestion.options, + }, + questionNumber: fresh.currentIndex + 1, + totalQuestions, + }); + // Restart timer for next round + const timer = setTimeout(async () => { + await resolveRound(lobbyId, fresh.currentIndex, totalQuestions); + }, 15000); + timers.set(lobbyId, timer); + }, 3000); + } +}; + +const endGame = async ( + lobbyId: string, + state: Awaited> & {}, +): Promise => { + // Persist final scores to DB + await finishGame(lobbyId, state.scores); + + // Determine winners (handle ties) + const maxScore = Math.max(...state.scores.values()); + const winnerIds = [...state.scores.entries()] + .filter(([, score]) => score === maxScore) + .map(([userId]) => userId); + + // Build final players array + const lobby = await getLobbyByCodeWithPlayers(state.code); + + const players = [...state.scores.entries()].map(([userId, score]) => { + const dbPlayer = lobby?.players.find((p) => p.userId === userId); + return { + lobbyId, + userId, + score, + user: { id: userId, name: dbPlayer?.user.name ?? userId }, + }; + }); + + broadcastToLobby(lobbyId, { type: "game:finished", players, winnerIds }); + + // Clean up game state + await lobbyGameStore.delete(lobbyId); + timers.delete(lobbyId); +}; diff --git a/apps/api/src/ws/handlers/lobbyHandlers.ts b/apps/api/src/ws/handlers/lobbyHandlers.ts index 88f2226..a02f2e2 100644 --- a/apps/api/src/ws/handlers/lobbyHandlers.ts +++ b/apps/api/src/ws/handlers/lobbyHandlers.ts @@ -1,13 +1,22 @@ 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 type { WsLobbyJoin, WsLobbyLeave, WsLobbyStart } from "@lila/shared"; +import { + getLobbyByCodeWithPlayers, + deleteLobby, + removePlayer, + updateLobbyStatus, +} from "@lila/db"; import { addConnection, + getConnections, removeConnection, broadcastToLobby, } from "../connections.js"; import { NotFoundError, ConflictError } from "../../errors/AppError.js"; +import { generateMultiplayerQuestions } from "../../services/multiplayerGameService.js"; +import { lobbyGameStore, timers } from "../gameState.js"; +import { resolveRound } from "./gameHandlers.js"; export const handleLobbyJoin = async ( ws: WebSocket, @@ -71,3 +80,76 @@ export const handleLobbyLeave = async ( // When reconnection handling is added, this is the place to change. } }; + +export const handleLobbyStart = async ( + _ws: WebSocket, + msg: WsLobbyStart, + user: User, +): Promise => { + // Load lobby and validate + const lobby = await getLobbyByCodeWithPlayers(msg.lobbyId); + if (!lobby) { + throw new NotFoundError("Lobby not found"); + } + if (lobby.hostUserId !== user.id) { + throw new ConflictError("Only the host can start the game"); + } + if (lobby.status !== "waiting") { + throw new ConflictError("Game has already started"); + } + + // Check connected players, not DB players + const connected = getConnections(msg.lobbyId); + if (connected.size < 2) { + throw new ConflictError("At least 2 players must be connected to start"); + } + + // Generate questions + const questions = await generateMultiplayerQuestions(); + + // Initialize scores for all connected players + const scores = new Map(); + for (const userId of connected.keys()) { + scores.set(userId, 0); + } + + // Initialize game state + await lobbyGameStore.create(msg.lobbyId, { + code: lobby.code, + questions, + currentIndex: 0, + playerAnswers: new Map(), + scores, + }); + + // Update lobby status in DB + await updateLobbyStatus(msg.lobbyId, "in_progress"); + + // Broadcast first question + const firstQuestion = questions[0]!; + broadcastToLobby(msg.lobbyId, { + type: "game:question", + question: { + questionId: firstQuestion.questionId, + prompt: firstQuestion.prompt, + gloss: firstQuestion.gloss, + options: firstQuestion.options, + }, + questionNumber: 1, + totalQuestions: questions.length, + }); + + // Start 15s timer + startRoundTimer(msg.lobbyId, 0, questions.length); +}; + +const startRoundTimer = ( + lobbyId: string, + questionIndex: number, + totalQuestions: number, +): void => { + const timer = setTimeout(async () => { + await resolveRound(lobbyId, questionIndex, totalQuestions); + }, 15000); + timers.set(lobbyId, timer); +};