lila/apps/api/src/ws/handlers/lobbyHandlers.ts
lila ce19740cc8 fix(lint): resolve all eslint errors across monorepo
- Type response bodies in gameController.test.ts to fix no-unsafe-member-access
- Replace async methods with Promise.resolve() in InMemoryGameSessionStore
  and InMemoryLobbyGameStore to satisfy require-await rule
- Add argsIgnorePattern and varsIgnorePattern to eslint config so
  underscore-prefixed params are globally ignored
- Fix no-misused-promises in ws/index.ts, lobbyHandlers, gameHandlers,
  __root.tsx, login.tsx and play.tsx by using void + .catch()
- Fix no-floating-promises on navigate calls in login.tsx
- Move API_URL outside Play component to fix useCallback dependency warning
- Type fetch response bodies in play.tsx to fix no-unsafe-assignment
- Add only-throw-error: off for route files (TanStack Router throw redirect)
- Remove unused WebSocket import from express.d.ts
- Fix unsafe return in connections.ts by typing empty Map constructor
- Exclude scripts/ folder from eslint
- Add targeted override for better-auth auth-client.ts (upstream typing issue)
2026-04-17 16:46:33 +02:00

157 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,
} 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 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<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);
};