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:
parent
745c5c4e3a
commit
7f56ad89e6
4 changed files with 274 additions and 2 deletions
|
|
@ -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.
|
||||
|
|
|
|||
4
apps/api/src/ws/gameState.ts
Normal file
4
apps/api/src/ws/gameState.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { InMemoryLobbyGameStore } from "../lobbyGameStore/index.js";
|
||||
|
||||
export const lobbyGameStore = new InMemoryLobbyGameStore();
|
||||
export const timers = new Map<string, NodeJS.Timeout>();
|
||||
185
apps/api/src/ws/handlers/gameHandlers.ts
Normal file
185
apps/api/src/ws/handlers/gameHandlers.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue