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:
lila 2026-04-18 23:32:21 +02:00
parent 540155788a
commit 8aaafea3fc
13 changed files with 545 additions and 78 deletions

View file

@ -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";

View 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",
);
});
});

View 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();
});
});

View file

@ -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");
} }

View 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"),
);
});
});

View file

@ -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/**"],
},
});

View file

@ -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();

View file

@ -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>,
);
} }

View file

@ -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>
); );

View file

@ -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>
); );

View file

@ -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>
); );

View file

@ -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 },
},
},
}); });

View file

@ -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,