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
This commit is contained in:
lila 2026-04-17 15:50:08 +02:00
parent 745c5c4e3a
commit 7f56ad89e6
4 changed files with 274 additions and 2 deletions

View file

@ -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.

View file

@ -0,0 +1,4 @@
import { InMemoryLobbyGameStore } from "../lobbyGameStore/index.js";
export const lobbyGameStore = new InMemoryLobbyGameStore();
export const timers = new Map<string, NodeJS.Timeout>();

View file

@ -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<void> => {
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<void> => {
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<ReturnType<typeof lobbyGameStore.get>> & {},
): Promise<void> => {
// 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);
};

View file

@ -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<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(async () => {
await resolveRound(lobbyId, questionIndex, totalQuestions);
}, 15000);
timers.set(lobbyId, timer);
};