WebSocket server: - WS auth via Better Auth session on upgrade request - Router with discriminated union dispatch and two-layer error handling - In-memory connections map with broadcastToLobby - Lobby handlers: join, leave, start - Game handlers: answer, resolve round, end game, game:ready for state sync - Shared game state store (LobbyGameStore interface + InMemory impl) - Timer map separate from store for Valkey-readiness REST API: - POST /api/v1/lobbies — create lobby + add host as first player - POST /api/v1/lobbies/:code/join — atomic join with capacity/status checks - getLobbyWithPlayers added to model for id-based lookup Frontend: - WsClient class with typed on/off, connect/disconnect, isConnected - WsProvider owns connection lifecycle (connect/disconnect/isConnected state) - WsConnector component triggers connection at multiplayer layout mount - Lobby waiting room: live player list, copyable code, host Start button - Game view: reuses QuestionCard, game:ready on mount, round results - MultiplayerScoreScreen: sorted scores, winner highlight, tie handling - Vite proxy: /ws and /api proxied to localhost:3000 for dev cookie fix Tests: - lobbyService.test.ts: create, join, retry, idempotency, full lobby - auth.test.ts: 401 reject, upgrade success, 500 on error - router.test.ts: dispatch all message types, error handling - vitest.config.ts: exclude dist folder Fixes: - server.ts: server.listen() instead of app.listen() for WS support - StrictMode removed from main.tsx (incompatible with WS lifecycle) - getLobbyWithPlayers(id) added for handleLobbyStart lookup
158 lines
4.6 KiB
TypeScript
158 lines
4.6 KiB
TypeScript
import type { WebSocket } from "ws";
|
|
import type { User } from "better-auth";
|
|
import type { WsLobbyJoin, WsLobbyLeave, WsLobbyStart } from "@lila/shared";
|
|
import {
|
|
getLobbyByCodeWithPlayers,
|
|
deleteLobby,
|
|
removePlayer,
|
|
updateLobbyStatus,
|
|
getLobbyByIdWithPlayers,
|
|
} 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<void> => {
|
|
// 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<void> => {
|
|
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<void> => {
|
|
// Load lobby and validate
|
|
const lobby = await getLobbyByIdWithPlayers(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<string, number>();
|
|
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);
|
|
};
|