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:
lila 2026-04-17 09:36:16 +02:00
parent b0aef8cc16
commit 745c5c4e3a
14 changed files with 443 additions and 1 deletions

View 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.
}
};