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";
|
import type { MultiplayerQuestion } from "../services/multiplayerGameService.js";
|
||||||
|
|
||||||
export type LobbyGameData = {
|
export type LobbyGameData = {
|
||||||
|
code: string;
|
||||||
questions: MultiplayerQuestion[];
|
questions: MultiplayerQuestion[];
|
||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
// NOTE: Map types are used here for O(1) lookups in-process.
|
// 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 { WebSocket } from "ws";
|
||||||
import type { User } from "better-auth";
|
import type { User } from "better-auth";
|
||||||
import type { WsLobbyJoin, WsLobbyLeave } from "@lila/shared";
|
import type { WsLobbyJoin, WsLobbyLeave, WsLobbyStart } from "@lila/shared";
|
||||||
import { getLobbyByCodeWithPlayers, deleteLobby, removePlayer } from "@lila/db";
|
import {
|
||||||
|
getLobbyByCodeWithPlayers,
|
||||||
|
deleteLobby,
|
||||||
|
removePlayer,
|
||||||
|
updateLobbyStatus,
|
||||||
|
} from "@lila/db";
|
||||||
import {
|
import {
|
||||||
addConnection,
|
addConnection,
|
||||||
|
getConnections,
|
||||||
removeConnection,
|
removeConnection,
|
||||||
broadcastToLobby,
|
broadcastToLobby,
|
||||||
} from "../connections.js";
|
} from "../connections.js";
|
||||||
import { NotFoundError, ConflictError } from "../../errors/AppError.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 (
|
export const handleLobbyJoin = async (
|
||||||
ws: WebSocket,
|
ws: WebSocket,
|
||||||
|
|
@ -71,3 +80,76 @@ export const handleLobbyLeave = async (
|
||||||
// When reconnection handling is added, this is the place to change.
|
// 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