feat: multiplayer slice — end to end working
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
This commit is contained in:
parent
540155788a
commit
8aaafea3fc
13 changed files with 545 additions and 78 deletions
176
apps/api/src/services/lobbyService.test.ts
Normal file
176
apps/api/src/services/lobbyService.test.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@lila/db", () => ({
|
||||
createLobby: vi.fn(),
|
||||
getLobbyByCodeWithPlayers: vi.fn(),
|
||||
addPlayer: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
createLobby as createLobbyModel,
|
||||
getLobbyByCodeWithPlayers,
|
||||
addPlayer,
|
||||
} from "@lila/db";
|
||||
import { createLobby, joinLobby } from "./lobbyService.js";
|
||||
|
||||
const mockCreateLobby = vi.mocked(createLobbyModel);
|
||||
const mockGetLobbyByCodeWithPlayers = vi.mocked(getLobbyByCodeWithPlayers);
|
||||
const mockAddPlayer = vi.mocked(addPlayer);
|
||||
|
||||
const fakeLobby = {
|
||||
id: "00000000-0000-4000-8000-000000000001",
|
||||
code: "ABC123",
|
||||
hostUserId: "user-1",
|
||||
status: "waiting" as const,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const fakeLobbyWithPlayers = {
|
||||
...fakeLobby,
|
||||
players: [
|
||||
{
|
||||
lobbyId: fakeLobby.id,
|
||||
userId: "user-1",
|
||||
score: 0,
|
||||
joinedAt: new Date(),
|
||||
user: { id: "user-1", name: "Alice" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateLobby.mockResolvedValue(fakeLobby);
|
||||
mockAddPlayer.mockResolvedValue({
|
||||
lobbyId: fakeLobby.id,
|
||||
userId: "user-1",
|
||||
score: 0,
|
||||
joinedAt: new Date(),
|
||||
});
|
||||
mockGetLobbyByCodeWithPlayers.mockResolvedValue(fakeLobbyWithPlayers);
|
||||
});
|
||||
|
||||
describe("createLobby", () => {
|
||||
it("creates a lobby and adds the host as the first player", async () => {
|
||||
const result = await createLobby("user-1");
|
||||
|
||||
expect(mockCreateLobby).toHaveBeenCalledOnce();
|
||||
expect(mockAddPlayer).toHaveBeenCalledWith(
|
||||
fakeLobby.id,
|
||||
"user-1",
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(result.id).toBe(fakeLobby.id);
|
||||
});
|
||||
|
||||
it("retries on unique code collision", async () => {
|
||||
const uniqueViolation = Object.assign(new Error("unique"), {
|
||||
code: "23505",
|
||||
});
|
||||
mockCreateLobby
|
||||
.mockRejectedValueOnce(uniqueViolation)
|
||||
.mockResolvedValueOnce(fakeLobby);
|
||||
|
||||
const result = await createLobby("user-1");
|
||||
|
||||
expect(mockCreateLobby).toHaveBeenCalledTimes(2);
|
||||
expect(result.id).toBe(fakeLobby.id);
|
||||
});
|
||||
|
||||
it("throws after max retry attempts", async () => {
|
||||
const uniqueViolation = Object.assign(new Error("unique"), {
|
||||
code: "23505",
|
||||
});
|
||||
mockCreateLobby.mockRejectedValue(uniqueViolation);
|
||||
|
||||
await expect(createLobby("user-1")).rejects.toThrow(
|
||||
"Could not generate a unique lobby code",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("joinLobby", () => {
|
||||
it("returns lobby with players when join succeeds", async () => {
|
||||
const fullLobby = {
|
||||
...fakeLobbyWithPlayers,
|
||||
players: [
|
||||
...fakeLobbyWithPlayers.players,
|
||||
{
|
||||
lobbyId: fakeLobby.id,
|
||||
userId: "user-2",
|
||||
score: 0,
|
||||
joinedAt: new Date(),
|
||||
user: { id: "user-2", name: "Bob" },
|
||||
},
|
||||
],
|
||||
};
|
||||
mockGetLobbyByCodeWithPlayers
|
||||
.mockResolvedValueOnce(fakeLobbyWithPlayers)
|
||||
.mockResolvedValueOnce(fullLobby);
|
||||
|
||||
const result = await joinLobby("ABC123", "user-2");
|
||||
|
||||
expect(mockAddPlayer).toHaveBeenCalledWith(
|
||||
fakeLobby.id,
|
||||
"user-2",
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(result.players).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("throws NotFoundError when lobby does not exist", async () => {
|
||||
mockGetLobbyByCodeWithPlayers.mockResolvedValue(undefined);
|
||||
|
||||
await expect(joinLobby("XXXXXX", "user-2")).rejects.toThrow(
|
||||
"Lobby not found",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws ConflictError when lobby is not waiting", async () => {
|
||||
mockGetLobbyByCodeWithPlayers.mockResolvedValue({
|
||||
...fakeLobbyWithPlayers,
|
||||
status: "in_progress" as const,
|
||||
});
|
||||
|
||||
await expect(joinLobby("ABC123", "user-2")).rejects.toThrow(
|
||||
"Game has already started",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns lobby idempotently when user already joined", async () => {
|
||||
mockGetLobbyByCodeWithPlayers.mockResolvedValue({
|
||||
...fakeLobbyWithPlayers,
|
||||
players: [
|
||||
{
|
||||
lobbyId: fakeLobby.id,
|
||||
userId: "user-1",
|
||||
score: 0,
|
||||
joinedAt: new Date(),
|
||||
user: { id: "user-1", name: "Alice" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await joinLobby("ABC123", "user-1");
|
||||
|
||||
expect(mockAddPlayer).not.toHaveBeenCalled();
|
||||
expect(result.players).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("throws ConflictError when lobby is full", async () => {
|
||||
mockGetLobbyByCodeWithPlayers.mockResolvedValue({
|
||||
...fakeLobbyWithPlayers,
|
||||
players: Array.from({ length: 4 }, (_, i) => ({
|
||||
lobbyId: fakeLobby.id,
|
||||
userId: `user-${i}`,
|
||||
score: 0,
|
||||
joinedAt: new Date(),
|
||||
user: { id: `user-${i}`, name: `Player ${i}` },
|
||||
})),
|
||||
});
|
||||
|
||||
await expect(joinLobby("ABC123", "user-5")).rejects.toThrow(
|
||||
"Lobby is full",
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue