feat(api): add WebSocket foundation and multiplayer game store
- Add ws/ directory: server setup, auth, router, connections map - WebSocket auth rejects upgrade with 401 if no Better Auth session - Router parses WsClientMessageSchema, dispatches to handlers, two-layer error handling (AppError -> WsErrorSchema, unknown -> 500) - connections.ts: in-memory Map<lobbyId, Map<userId, WebSocket>> with addConnection, removeConnection, broadcastToLobby - LobbyGameStore interface + InMemoryLobbyGameStore implementation following existing GameSessionStore pattern - multiplayerGameService: generateMultiplayerQuestions() decoupled from single-player flow, hardcoded defaults en->it nouns easy 3 rounds - handleLobbyJoin and handleLobbyLeave implemented - WsErrorSchema added to shared schemas - server.ts switched to createServer + setupWebSocket
This commit is contained in:
parent
b0aef8cc16
commit
745c5c4e3a
14 changed files with 443 additions and 1 deletions
73
apps/api/src/ws/handlers/lobbyHandlers.ts
Normal file
73
apps/api/src/ws/handlers/lobbyHandlers.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
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 {
|
||||
addConnection,
|
||||
removeConnection,
|
||||
broadcastToLobby,
|
||||
} from "../connections.js";
|
||||
import { NotFoundError, ConflictError } from "../../errors/AppError.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.
|
||||
}
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue