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
|
|
@ -7,7 +7,46 @@ type ErrorResponse = { success: false; error: string };
|
||||||
type GameStartResponse = SuccessResponse<GameSession>;
|
type GameStartResponse = SuccessResponse<GameSession>;
|
||||||
type GameAnswerResponse = SuccessResponse<AnswerResult>;
|
type GameAnswerResponse = SuccessResponse<AnswerResult>;
|
||||||
|
|
||||||
vi.mock("@lila/db", () => ({ getGameTerms: vi.fn(), getDistractors: vi.fn() }));
|
vi.mock("@lila/db", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@lila/db")>();
|
||||||
|
return { ...actual, getGameTerms: vi.fn(), getDistractors: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../lib/auth.js", () => ({
|
||||||
|
auth: {
|
||||||
|
api: {
|
||||||
|
getSession: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({
|
||||||
|
session: {
|
||||||
|
id: "session-1",
|
||||||
|
userId: "user-1",
|
||||||
|
token: "fake-token",
|
||||||
|
expiresAt: new Date(Date.now() + 1000 * 60 * 60),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
ipAddress: null,
|
||||||
|
userAgent: null,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: "user-1",
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@test.com",
|
||||||
|
emailVerified: false,
|
||||||
|
image: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
handler: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("better-auth/node", () => ({
|
||||||
|
fromNodeHeaders: vi.fn().mockReturnValue({}),
|
||||||
|
toNodeHandler: vi.fn().mockReturnValue(vi.fn()),
|
||||||
|
}));
|
||||||
|
|
||||||
import { getGameTerms, getDistractors } from "@lila/db";
|
import { getGameTerms, getDistractors } from "@lila/db";
|
||||||
import { createApp } from "../app.js";
|
import { createApp } from "../app.js";
|
||||||
|
|
|
||||||
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
106
apps/api/src/ws/auth.test.ts
Normal file
106
apps/api/src/ws/auth.test.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { IncomingMessage } from "http";
|
||||||
|
import { Duplex } from "stream";
|
||||||
|
|
||||||
|
vi.mock("better-auth/node", () => ({
|
||||||
|
fromNodeHeaders: vi.fn().mockReturnValue({}),
|
||||||
|
toNodeHandler: vi.fn().mockReturnValue(vi.fn()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../lib/auth.js", () => ({
|
||||||
|
auth: { api: { getSession: vi.fn() }, handler: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { auth } from "../lib/auth.js";
|
||||||
|
import { handleUpgrade } from "./auth.js";
|
||||||
|
|
||||||
|
const mockGetSession = vi.mocked(auth.api.getSession);
|
||||||
|
|
||||||
|
const fakeSession = {
|
||||||
|
session: {
|
||||||
|
id: "session-1",
|
||||||
|
userId: "user-1",
|
||||||
|
token: "fake-token",
|
||||||
|
expiresAt: new Date(Date.now() + 1000 * 60 * 60),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
ipAddress: null,
|
||||||
|
userAgent: null,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: "user-1",
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@test.com",
|
||||||
|
emailVerified: false,
|
||||||
|
image: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeMockSocket = () => {
|
||||||
|
const socket = new Duplex();
|
||||||
|
socket._read = () => {};
|
||||||
|
socket._write = (_chunk, _encoding, callback) => callback();
|
||||||
|
const writeSpy = vi.spyOn(socket, "write").mockImplementation(() => true);
|
||||||
|
const destroySpy = vi
|
||||||
|
.spyOn(socket, "destroy")
|
||||||
|
.mockImplementation(() => socket);
|
||||||
|
return { socket, writeSpy, destroySpy };
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeMockRequest = () => {
|
||||||
|
const req = new IncomingMessage(null as never);
|
||||||
|
req.headers = {};
|
||||||
|
return req;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeMockWss = () => ({
|
||||||
|
handleUpgrade: vi.fn((_req, _socket, _head, cb: (ws: unknown) => void) => {
|
||||||
|
cb({ send: vi.fn(), on: vi.fn() });
|
||||||
|
}),
|
||||||
|
emit: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleUpgrade", () => {
|
||||||
|
it("rejects with 401 when no session exists", async () => {
|
||||||
|
mockGetSession.mockResolvedValue(null);
|
||||||
|
const { socket, writeSpy, destroySpy } = makeMockSocket();
|
||||||
|
const req = makeMockRequest();
|
||||||
|
const wss = makeMockWss();
|
||||||
|
await handleUpgrade(req, socket, Buffer.alloc(0), wss as never);
|
||||||
|
expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining("401"));
|
||||||
|
expect(destroySpy).toHaveBeenCalled();
|
||||||
|
expect(wss.handleUpgrade).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("upgrades connection when session exists", async () => {
|
||||||
|
mockGetSession.mockResolvedValue(fakeSession);
|
||||||
|
const { socket, destroySpy } = makeMockSocket();
|
||||||
|
const req = makeMockRequest();
|
||||||
|
const wss = makeMockWss();
|
||||||
|
await handleUpgrade(req, socket, Buffer.alloc(0), wss as never);
|
||||||
|
expect(wss.handleUpgrade).toHaveBeenCalled();
|
||||||
|
expect(wss.emit).toHaveBeenCalledWith(
|
||||||
|
"connection",
|
||||||
|
expect.anything(),
|
||||||
|
req,
|
||||||
|
fakeSession,
|
||||||
|
);
|
||||||
|
expect(destroySpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects with 500 when getSession throws", async () => {
|
||||||
|
mockGetSession.mockRejectedValue(new Error("DB error"));
|
||||||
|
const { socket, writeSpy, destroySpy } = makeMockSocket();
|
||||||
|
const req = makeMockRequest();
|
||||||
|
const wss = makeMockWss();
|
||||||
|
await handleUpgrade(req, socket, Buffer.alloc(0), wss as never);
|
||||||
|
expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining("500"));
|
||||||
|
expect(destroySpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
deleteLobby,
|
deleteLobby,
|
||||||
removePlayer,
|
removePlayer,
|
||||||
updateLobbyStatus,
|
updateLobbyStatus,
|
||||||
|
getLobbyByIdWithPlayers,
|
||||||
} from "@lila/db";
|
} from "@lila/db";
|
||||||
import {
|
import {
|
||||||
addConnection,
|
addConnection,
|
||||||
|
|
@ -87,7 +88,7 @@ export const handleLobbyStart = async (
|
||||||
user: User,
|
user: User,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
// Load lobby and validate
|
// Load lobby and validate
|
||||||
const lobby = await getLobbyByCodeWithPlayers(msg.lobbyId);
|
const lobby = await getLobbyByIdWithPlayers(msg.lobbyId);
|
||||||
if (!lobby) {
|
if (!lobby) {
|
||||||
throw new NotFoundError("Lobby not found");
|
throw new NotFoundError("Lobby not found");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
125
apps/api/src/ws/router.test.ts
Normal file
125
apps/api/src/ws/router.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("./handlers/lobbyHandlers.js", () => ({
|
||||||
|
handleLobbyJoin: vi.fn(),
|
||||||
|
handleLobbyLeave: vi.fn(),
|
||||||
|
handleLobbyStart: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./handlers/gameHandlers.js", () => ({
|
||||||
|
handleGameAnswer: vi.fn(),
|
||||||
|
handleGameReady: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { handleMessage } from "./router.js";
|
||||||
|
import {
|
||||||
|
handleLobbyJoin,
|
||||||
|
handleLobbyLeave,
|
||||||
|
handleLobbyStart,
|
||||||
|
} from "./handlers/lobbyHandlers.js";
|
||||||
|
import { handleGameAnswer, handleGameReady } from "./handlers/gameHandlers.js";
|
||||||
|
|
||||||
|
const mockHandleLobbyJoin = vi.mocked(handleLobbyJoin);
|
||||||
|
const mockHandleLobbyLeave = vi.mocked(handleLobbyLeave);
|
||||||
|
const mockHandleLobbyStart = vi.mocked(handleLobbyStart);
|
||||||
|
const mockHandleGameAnswer = vi.mocked(handleGameAnswer);
|
||||||
|
const mockHandleGameReady = vi.mocked(handleGameReady);
|
||||||
|
|
||||||
|
const fakeWs = { send: vi.fn(), readyState: 1, OPEN: 1 };
|
||||||
|
|
||||||
|
const FAKE_LOBBY_ID = "00000000-0000-4000-8000-000000000001";
|
||||||
|
const FAKE_QUESTION_ID = "00000000-0000-4000-8000-000000000002";
|
||||||
|
|
||||||
|
const fakeAuth = {
|
||||||
|
session: {
|
||||||
|
id: "session-1",
|
||||||
|
userId: "user-1",
|
||||||
|
token: "fake-token",
|
||||||
|
expiresAt: new Date(Date.now() + 1000 * 60 * 60),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
ipAddress: null,
|
||||||
|
userAgent: null,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: "user-1",
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@test.com",
|
||||||
|
emailVerified: false,
|
||||||
|
image: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleMessage", () => {
|
||||||
|
it("dispatches lobby:join to handleLobbyJoin", async () => {
|
||||||
|
const msg = JSON.stringify({ type: "lobby:join", code: "ABC123" });
|
||||||
|
await handleMessage(fakeWs as never, msg, fakeAuth);
|
||||||
|
expect(mockHandleLobbyJoin).toHaveBeenCalledWith(
|
||||||
|
fakeWs,
|
||||||
|
{ type: "lobby:join", code: "ABC123" },
|
||||||
|
fakeAuth.user,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches lobby:leave to handleLobbyLeave", async () => {
|
||||||
|
const msg = JSON.stringify({ type: "lobby:leave", lobbyId: FAKE_LOBBY_ID });
|
||||||
|
await handleMessage(fakeWs as never, msg, fakeAuth);
|
||||||
|
expect(mockHandleLobbyLeave).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches lobby:start to handleLobbyStart", async () => {
|
||||||
|
const msg = JSON.stringify({ type: "lobby:start", lobbyId: FAKE_LOBBY_ID });
|
||||||
|
await handleMessage(fakeWs as never, msg, fakeAuth);
|
||||||
|
expect(mockHandleLobbyStart).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches game:answer to handleGameAnswer", async () => {
|
||||||
|
const msg = JSON.stringify({
|
||||||
|
type: "game:answer",
|
||||||
|
lobbyId: FAKE_LOBBY_ID,
|
||||||
|
questionId: FAKE_QUESTION_ID,
|
||||||
|
selectedOptionId: 2,
|
||||||
|
});
|
||||||
|
await handleMessage(fakeWs as never, msg, fakeAuth);
|
||||||
|
expect(mockHandleGameAnswer).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches game:ready to handleGameReady", async () => {
|
||||||
|
const msg = JSON.stringify({ type: "game:ready", lobbyId: FAKE_LOBBY_ID });
|
||||||
|
await handleMessage(fakeWs as never, msg, fakeAuth);
|
||||||
|
expect(mockHandleGameReady).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends error message for invalid JSON", async () => {
|
||||||
|
await handleMessage(fakeWs as never, "not json", fakeAuth);
|
||||||
|
expect(fakeWs.send).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Invalid JSON"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends error message for unknown message type", async () => {
|
||||||
|
const msg = JSON.stringify({ type: "unknown:type" });
|
||||||
|
await handleMessage(fakeWs as never, msg, fakeAuth);
|
||||||
|
expect(fakeWs.send).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Invalid message format"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends error message when handler throws AppError", async () => {
|
||||||
|
const { AppError } = await import("../errors/AppError.js");
|
||||||
|
mockHandleLobbyJoin.mockRejectedValueOnce(
|
||||||
|
new AppError("Lobby not found", 404),
|
||||||
|
);
|
||||||
|
const msg = JSON.stringify({ type: "lobby:join", code: "ABC123" });
|
||||||
|
await handleMessage(fakeWs as never, msg, fakeAuth);
|
||||||
|
expect(fakeWs.send).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Lobby not found"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,3 +1,9 @@
|
||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
export default defineConfig({ test: { environment: "node", globals: true } });
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
globals: true,
|
||||||
|
exclude: ["**/dist/**", "**/node_modules/**"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -25,25 +25,27 @@ export class WsClient {
|
||||||
public onClose: ((event: CloseEvent) => void) | null = null;
|
public onClose: ((event: CloseEvent) => void) | null = null;
|
||||||
|
|
||||||
connect(apiUrl: string): Promise<void> {
|
connect(apiUrl: string): Promise<void> {
|
||||||
// If already connected or connecting, resolve immediately
|
|
||||||
if (
|
|
||||||
this.ws &&
|
|
||||||
(this.ws.readyState === WebSocket.OPEN ||
|
|
||||||
this.ws.readyState === WebSocket.CONNECTING)
|
|
||||||
) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (this.ws) {
|
if (
|
||||||
this.ws.close();
|
this.ws &&
|
||||||
this.ws = null;
|
(this.ws.readyState === WebSocket.OPEN ||
|
||||||
|
this.ws.readyState === WebSocket.CONNECTING)
|
||||||
|
) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const wsUrl = apiUrl
|
let wsUrl: string;
|
||||||
.replace(/^https:\/\//, "wss://")
|
if (!apiUrl) {
|
||||||
.replace(/^http:\/\//, "ws://");
|
wsUrl = "/ws";
|
||||||
|
} else {
|
||||||
|
wsUrl =
|
||||||
|
apiUrl
|
||||||
|
.replace(/^https:\/\//, "wss://")
|
||||||
|
.replace(/^http:\/\//, "ws://") + "/ws";
|
||||||
|
}
|
||||||
|
|
||||||
this.ws = new WebSocket(`${wsUrl}/ws`);
|
this.ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
this.ws.onopen = () => {
|
this.ws.onopen = () => {
|
||||||
resolve();
|
resolve();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { StrictMode } from "react";
|
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
@ -20,9 +19,5 @@ declare module "@tanstack/react-router" {
|
||||||
const rootElement = document.getElementById("root")!;
|
const rootElement = document.getElementById("root")!;
|
||||||
if (!rootElement.innerHTML) {
|
if (!rootElement.innerHTML) {
|
||||||
const root = ReactDOM.createRoot(rootElement);
|
const root = ReactDOM.createRoot(rootElement);
|
||||||
root.render(
|
root.render(<RouterProvider router={router} />);
|
||||||
<StrictMode>
|
|
||||||
<RouterProvider router={router} />
|
|
||||||
</StrictMode>,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
|
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
|
||||||
import { WsProvider } from "../lib/ws-provider.js";
|
import { useEffect } from "react";
|
||||||
import { authClient } from "../lib/auth-client.js";
|
import { authClient } from "../lib/auth-client.js";
|
||||||
|
import { WsProvider } from "../lib/ws-provider.js";
|
||||||
|
import { useWsConnect } from "../lib/ws-hooks.js";
|
||||||
|
|
||||||
|
const wsBaseUrl =
|
||||||
|
(import.meta.env["VITE_WS_URL"] as string) ||
|
||||||
|
(import.meta.env["VITE_API_URL"] as string) ||
|
||||||
|
"";
|
||||||
|
|
||||||
export const Route = createFileRoute("/multiplayer")({
|
export const Route = createFileRoute("/multiplayer")({
|
||||||
component: MultiplayerLayout,
|
component: MultiplayerLayout,
|
||||||
|
|
@ -13,9 +20,23 @@ export const Route = createFileRoute("/multiplayer")({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function WsConnector() {
|
||||||
|
const connect = useWsConnect();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void connect(wsBaseUrl).catch((err) => {
|
||||||
|
console.error("WebSocket connection failed:", err);
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function MultiplayerLayout() {
|
function MultiplayerLayout() {
|
||||||
return (
|
return (
|
||||||
<WsProvider>
|
<WsProvider>
|
||||||
|
<WsConnector />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</WsProvider>
|
</WsProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { useWsClient, useWsConnect } from "../../lib/ws-hooks.js";
|
import { useWsClient, useWsConnected } from "../../lib/ws-hooks.js";
|
||||||
import { QuestionCard } from "../../components/game/QuestionCard.js";
|
import { QuestionCard } from "../../components/game/QuestionCard.js";
|
||||||
import { MultiplayerScoreScreen } from "../../components/multiplayer/MultiplayerScoreScreen.js";
|
import { MultiplayerScoreScreen } from "../../components/multiplayer/MultiplayerScoreScreen.js";
|
||||||
import { GameRouteSearchSchema } from "@lila/shared";
|
import { GameRouteSearchSchema } from "@lila/shared";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
WsGameQuestion,
|
WsGameQuestion,
|
||||||
WsGameAnswerResult,
|
WsGameAnswerResult,
|
||||||
|
|
@ -12,8 +11,6 @@ import type {
|
||||||
WsError,
|
WsError,
|
||||||
} from "@lila/shared";
|
} from "@lila/shared";
|
||||||
|
|
||||||
const API_URL = (import.meta.env["VITE_API_URL"] as string) || "";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/multiplayer/game/$code")({
|
export const Route = createFileRoute("/multiplayer/game/$code")({
|
||||||
component: GamePage,
|
component: GamePage,
|
||||||
validateSearch: GameRouteSearchSchema,
|
validateSearch: GameRouteSearchSchema,
|
||||||
|
|
@ -25,7 +22,7 @@ function GamePage() {
|
||||||
const { session } = Route.useRouteContext();
|
const { session } = Route.useRouteContext();
|
||||||
const currentUserId = session.user.id;
|
const currentUserId = session.user.id;
|
||||||
const client = useWsClient();
|
const client = useWsClient();
|
||||||
const connect = useWsConnect();
|
const isConnected = useWsConnected();
|
||||||
|
|
||||||
const [currentQuestion, setCurrentQuestion] = useState<WsGameQuestion | null>(
|
const [currentQuestion, setCurrentQuestion] = useState<WsGameQuestion | null>(
|
||||||
null,
|
null,
|
||||||
|
|
@ -36,7 +33,6 @@ function GamePage() {
|
||||||
const [gameFinished, setGameFinished] = useState<WsGameFinished | null>(null);
|
const [gameFinished, setGameFinished] = useState<WsGameFinished | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [hasAnswered, setHasAnswered] = useState(false);
|
const [hasAnswered, setHasAnswered] = useState(false);
|
||||||
const isConnectedRef = useRef(false);
|
|
||||||
|
|
||||||
const handleGameQuestion = useCallback((msg: WsGameQuestion) => {
|
const handleGameQuestion = useCallback((msg: WsGameQuestion) => {
|
||||||
setCurrentQuestion(msg);
|
setCurrentQuestion(msg);
|
||||||
|
|
@ -58,25 +54,14 @@ function GamePage() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isConnected) return;
|
||||||
|
|
||||||
client.on("game:question", handleGameQuestion);
|
client.on("game:question", handleGameQuestion);
|
||||||
client.on("game:answer_result", handleAnswerResult);
|
client.on("game:answer_result", handleAnswerResult);
|
||||||
client.on("game:finished", handleGameFinished);
|
client.on("game:finished", handleGameFinished);
|
||||||
client.on("error", handleWsError);
|
client.on("error", handleWsError);
|
||||||
|
|
||||||
if (!client.isConnected()) {
|
client.send({ type: "game:ready", lobbyId });
|
||||||
void connect(API_URL)
|
|
||||||
.then(() => {
|
|
||||||
client.send({ type: "game:ready", lobbyId });
|
|
||||||
isConnectedRef.current = true;
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error("Failed to connect to WebSocket:", err);
|
|
||||||
setError("Could not connect to server. Please try again.");
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
client.send({ type: "game:ready", lobbyId });
|
|
||||||
isConnectedRef.current = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
client.off("game:question", handleGameQuestion);
|
client.off("game:question", handleGameQuestion);
|
||||||
|
|
@ -84,10 +69,8 @@ function GamePage() {
|
||||||
client.off("game:finished", handleGameFinished);
|
client.off("game:finished", handleGameFinished);
|
||||||
client.off("error", handleWsError);
|
client.off("error", handleWsError);
|
||||||
};
|
};
|
||||||
// stable deps: client, connect, lobbyId is a search param stable for
|
|
||||||
// this route. handlers wrapped in useCallback with stable deps.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, [isConnected]);
|
||||||
|
|
||||||
const handleAnswer = useCallback(
|
const handleAnswer = useCallback(
|
||||||
(optionId: number) => {
|
(optionId: number) => {
|
||||||
|
|
@ -116,11 +99,11 @@ function GamePage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase: loading
|
// Phase: loading
|
||||||
if (!currentQuestion) {
|
if (!isConnected || !currentQuestion) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center">
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center">
|
||||||
<p className="text-purple-400 text-lg font-medium">
|
<p className="text-purple-400 text-lg font-medium">
|
||||||
{error ?? "Loading game..."}
|
{error ?? (isConnected ? "Loading game..." : "Connecting...")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import {
|
import { useWsClient, useWsConnected } from "../../lib/ws-hooks.js";
|
||||||
useWsClient,
|
|
||||||
useWsConnect,
|
|
||||||
useWsDisconnect,
|
|
||||||
} from "../../lib/ws-hooks.js";
|
|
||||||
import type {
|
import type {
|
||||||
Lobby,
|
Lobby,
|
||||||
WsLobbyState,
|
WsLobbyState,
|
||||||
|
|
@ -12,8 +8,6 @@ import type {
|
||||||
WsGameQuestion,
|
WsGameQuestion,
|
||||||
} from "@lila/shared";
|
} from "@lila/shared";
|
||||||
|
|
||||||
const API_URL = (import.meta.env["VITE_API_URL"] as string) || "";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/multiplayer/lobby/$code")({
|
export const Route = createFileRoute("/multiplayer/lobby/$code")({
|
||||||
component: LobbyPage,
|
component: LobbyPage,
|
||||||
});
|
});
|
||||||
|
|
@ -24,13 +18,11 @@ function LobbyPage() {
|
||||||
const currentUserId = session.user.id;
|
const currentUserId = session.user.id;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const client = useWsClient();
|
const client = useWsClient();
|
||||||
const connect = useWsConnect();
|
const isConnected = useWsConnected();
|
||||||
const disconnect = useWsDisconnect();
|
|
||||||
|
|
||||||
const [lobby, setLobby] = useState<Lobby | null>(null);
|
const [lobby, setLobby] = useState<Lobby | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isStarting, setIsStarting] = useState(false);
|
const [isStarting, setIsStarting] = useState(false);
|
||||||
|
|
||||||
const lobbyIdRef = useRef<string | null>(null);
|
const lobbyIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const handleLobbyState = useCallback((msg: WsLobbyState) => {
|
const handleLobbyState = useCallback((msg: WsLobbyState) => {
|
||||||
|
|
@ -56,31 +48,24 @@ function LobbyPage() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isConnected) return;
|
||||||
|
|
||||||
client.on("lobby:state", handleLobbyState);
|
client.on("lobby:state", handleLobbyState);
|
||||||
client.on("game:question", handleGameQuestion);
|
client.on("game:question", handleGameQuestion);
|
||||||
client.on("error", handleWsError);
|
client.on("error", handleWsError);
|
||||||
|
|
||||||
void connect(API_URL)
|
client.send({ type: "lobby:join", code });
|
||||||
.then(() => {
|
|
||||||
client.send({ type: "lobby:join", code });
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error("Failed to connect to WebSocket:", err);
|
|
||||||
setError("Could not connect to server. Please try again.");
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
client.off("lobby:state", handleLobbyState);
|
client.off("lobby:state", handleLobbyState);
|
||||||
client.off("game:question", handleGameQuestion);
|
client.off("game:question", handleGameQuestion);
|
||||||
client.off("error", handleWsError);
|
client.off("error", handleWsError);
|
||||||
client.send({ type: "lobby:leave", lobbyId: lobby?.id ?? "" });
|
if (lobbyIdRef.current) {
|
||||||
disconnect();
|
client.send({ type: "lobby:leave", lobbyId: lobbyIdRef.current });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
// Effect runs once on mount. All referenced values are stable:
|
|
||||||
// client/connect/disconnect from context (useCallback), handlers
|
|
||||||
// wrapped in useCallback, code is a URL param. lobbyIdRef is a ref.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, [isConnected]);
|
||||||
|
|
||||||
const handleStart = useCallback(() => {
|
const handleStart = useCallback(() => {
|
||||||
if (!lobby) return;
|
if (!lobby) return;
|
||||||
|
|
@ -88,11 +73,11 @@ function LobbyPage() {
|
||||||
client.send({ type: "lobby:start", lobbyId: lobby.id });
|
client.send({ type: "lobby:start", lobbyId: lobby.id });
|
||||||
}, [lobby, client]);
|
}, [lobby, client]);
|
||||||
|
|
||||||
if (!lobby) {
|
if (!isConnected || !lobby) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center">
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center">
|
||||||
<p className="text-purple-400 text-lg font-medium">
|
<p className="text-purple-400 text-lg font-medium">
|
||||||
{error ?? "Connecting..."}
|
{error ?? (isConnected ? "Joining lobby..." : "Connecting...")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,22 @@ export default defineConfig({
|
||||||
react(),
|
react(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
],
|
],
|
||||||
server: { proxy: { "/api": "http://localhost:3000" } },
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/ws": {
|
||||||
|
target: "http://localhost:3000",
|
||||||
|
ws: true,
|
||||||
|
rewriteWsOrigin: true,
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on("error", (err) => {
|
||||||
|
console.log("[ws proxy error]", err.message);
|
||||||
|
});
|
||||||
|
proxy.on("proxyReqWs", (_proxyReq, req) => {
|
||||||
|
console.log("[ws proxy] forwarding", req.url);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api": { target: "http://localhost:3000", changeOrigin: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,17 @@ export const getLobbyByCodeWithPlayers = async (
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 (
|
export const updateLobbyStatus = async (
|
||||||
lobbyId: string,
|
lobbyId: string,
|
||||||
status: LobbyStatus,
|
status: LobbyStatus,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue