WebSocket server: - WS auth via Better Auth session on upgrade request - Router with discriminated union dispatch and two-layer error handling - In-memory connections map with broadcastToLobby - Lobby handlers: join, leave, start - Game handlers: answer, resolve round, end game, game:ready for state sync - Shared game state store (LobbyGameStore interface + InMemory impl) - Timer map separate from store for Valkey-readiness REST API: - POST /api/v1/lobbies — create lobby + add host as first player - POST /api/v1/lobbies/:code/join — atomic join with capacity/status checks - getLobbyWithPlayers added to model for id-based lookup Frontend: - WsClient class with typed on/off, connect/disconnect, isConnected - WsProvider owns connection lifecycle (connect/disconnect/isConnected state) - WsConnector component triggers connection at multiplayer layout mount - Lobby waiting room: live player list, copyable code, host Start button - Game view: reuses QuestionCard, game:ready on mount, round results - MultiplayerScoreScreen: sorted scores, winner highlight, tie handling - Vite proxy: /ws and /api proxied to localhost:3000 for dev cookie fix Tests: - lobbyService.test.ts: create, join, retry, idempotency, full lobby - auth.test.ts: 401 reject, upgrade success, 500 on error - router.test.ts: dispatch all message types, error handling - vitest.config.ts: exclude dist folder Fixes: - server.ts: server.listen() instead of app.listen() for WS support - StrictMode removed from main.tsx (incompatible with WS lifecycle) - getLobbyWithPlayers(id) added for handleLobbyStart lookup
133 lines
3.5 KiB
TypeScript
133 lines
3.5 KiB
TypeScript
import { db } from "@lila/db";
|
|
import { lobbies, lobby_players } from "@lila/db/schema";
|
|
import { eq, and, sql } from "drizzle-orm";
|
|
|
|
import type { LobbyStatus } from "@lila/shared";
|
|
|
|
export type Lobby = typeof lobbies.$inferSelect;
|
|
export type LobbyPlayer = typeof lobby_players.$inferSelect;
|
|
export type LobbyWithPlayers = Lobby & {
|
|
players: (LobbyPlayer & { user: { id: string; name: string } })[];
|
|
};
|
|
|
|
export const createLobby = async (
|
|
code: string,
|
|
hostUserId: string,
|
|
): Promise<Lobby> => {
|
|
const [newLobby] = await db
|
|
.insert(lobbies)
|
|
.values({ code, hostUserId, status: "waiting" })
|
|
.returning();
|
|
|
|
if (!newLobby) {
|
|
throw new Error("Failed to create lobby");
|
|
}
|
|
|
|
return newLobby;
|
|
};
|
|
|
|
export const getLobbyByCodeWithPlayers = async (
|
|
code: string,
|
|
): Promise<LobbyWithPlayers | undefined> => {
|
|
return db.query.lobbies.findFirst({
|
|
where: eq(lobbies.code, code),
|
|
with: {
|
|
players: { with: { user: { columns: { id: true, name: true } } } },
|
|
},
|
|
});
|
|
};
|
|
|
|
export const getLobbyByIdWithPlayers = async (
|
|
lobbyId: string,
|
|
): Promise<LobbyWithPlayers | undefined> => {
|
|
return db.query.lobbies.findFirst({
|
|
where: eq(lobbies.id, lobbyId),
|
|
with: {
|
|
players: { with: { user: { columns: { id: true, name: true } } } },
|
|
},
|
|
});
|
|
};
|
|
|
|
export const updateLobbyStatus = async (
|
|
lobbyId: string,
|
|
status: LobbyStatus,
|
|
): Promise<void> => {
|
|
await db.update(lobbies).set({ status }).where(eq(lobbies.id, lobbyId));
|
|
};
|
|
|
|
export const deleteLobby = async (lobbyId: string): Promise<void> => {
|
|
await db.delete(lobbies).where(eq(lobbies.id, lobbyId));
|
|
};
|
|
|
|
/**
|
|
* Atomically inserts a player into a lobby. Returns the new player row,
|
|
* or undefined if the insert was skipped because:
|
|
* - the lobby is at capacity, or
|
|
* - the lobby is not in 'waiting' status, or
|
|
* - the user is already in the lobby (PK conflict).
|
|
*
|
|
* Callers are expected to pre-check these conditions against a hydrated
|
|
* lobby state to produce specific error messages; the undefined return
|
|
* is a safety net for concurrent races.
|
|
*/
|
|
export const addPlayer = async (
|
|
lobbyId: string,
|
|
userId: string,
|
|
maxPlayers: number,
|
|
): Promise<LobbyPlayer | undefined> => {
|
|
const result = await db.execute(sql`
|
|
INSERT INTO lobby_players (lobby_id, user_id)
|
|
SELECT ${lobbyId}::uuid, ${userId}
|
|
WHERE (
|
|
SELECT COUNT(*) FROM lobby_players WHERE lobby_id = ${lobbyId}::uuid
|
|
) < ${maxPlayers}
|
|
AND EXISTS (
|
|
SELECT 1 FROM lobbies WHERE id = ${lobbyId}::uuid AND status = 'waiting'
|
|
)
|
|
ON CONFLICT (lobby_id, user_id) DO NOTHING
|
|
`);
|
|
|
|
if (!result.rowCount) return undefined;
|
|
const [player] = await db
|
|
.select()
|
|
.from(lobby_players)
|
|
.where(
|
|
and(eq(lobby_players.lobbyId, lobbyId), eq(lobby_players.userId, userId)),
|
|
);
|
|
|
|
return player;
|
|
};
|
|
|
|
export const removePlayer = async (
|
|
lobbyId: string,
|
|
userId: string,
|
|
): Promise<void> => {
|
|
await db
|
|
.delete(lobby_players)
|
|
.where(
|
|
and(eq(lobby_players.lobbyId, lobbyId), eq(lobby_players.userId, userId)),
|
|
);
|
|
};
|
|
|
|
export const finishGame = async (
|
|
lobbyId: string,
|
|
scoresByUser: Map<string, number>,
|
|
): Promise<void> => {
|
|
await db.transaction(async (tx) => {
|
|
for (const [userId, score] of scoresByUser) {
|
|
await tx
|
|
.update(lobby_players)
|
|
.set({ score })
|
|
.where(
|
|
and(
|
|
eq(lobby_players.lobbyId, lobbyId),
|
|
eq(lobby_players.userId, userId),
|
|
),
|
|
);
|
|
}
|
|
await tx
|
|
.update(lobbies)
|
|
.set({ status: "finished" })
|
|
.where(eq(lobbies.id, lobbyId));
|
|
});
|
|
};
|