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