import type { WebSocket } from "ws"; import type { User } from "better-auth"; 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, 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. } }; 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(() => { void resolveRound(lobbyId, questionIndex, totalQuestions).catch((err) => { console.error("Error resolving round after timeout:", err); }); }, 15000); timers.set(lobbyId, timer); };