lila/apps/web/src/routes/multiplayer.tsx
lila 8aaafea3fc 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
2026-04-18 23:32:21 +02:00

43 lines
1 KiB
TypeScript

import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
import { useEffect } from "react";
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")({
component: MultiplayerLayout,
beforeLoad: async () => {
const { data: session } = await authClient.getSession();
if (!session) {
throw redirect({ to: "/login" });
}
return { session };
},
});
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() {
return (
<WsProvider>
<WsConnector />
<Outlet />
</WsProvider>
);
}