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
|
|
@ -25,25 +25,27 @@ export class WsClient {
|
|||
public onClose: ((event: CloseEvent) => void) | null = null;
|
||||
|
||||
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) => {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
if (
|
||||
this.ws &&
|
||||
(this.ws.readyState === WebSocket.OPEN ||
|
||||
this.ws.readyState === WebSocket.CONNECTING)
|
||||
) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const wsUrl = apiUrl
|
||||
.replace(/^https:\/\//, "wss://")
|
||||
.replace(/^http:\/\//, "ws://");
|
||||
let wsUrl: string;
|
||||
if (!apiUrl) {
|
||||
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 = () => {
|
||||
resolve();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { StrictMode } from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import "./index.css";
|
||||
|
|
@ -20,9 +19,5 @@ declare module "@tanstack/react-router" {
|
|||
const rootElement = document.getElementById("root")!;
|
||||
if (!rootElement.innerHTML) {
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>,
|
||||
);
|
||||
root.render(<RouterProvider router={router} />);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
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 { 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,
|
||||
|
|
@ -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() {
|
||||
return (
|
||||
<WsProvider>
|
||||
<WsConnector />
|
||||
<Outlet />
|
||||
</WsProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useWsClient, useWsConnect } from "../../lib/ws-hooks.js";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useWsClient, useWsConnected } from "../../lib/ws-hooks.js";
|
||||
import { QuestionCard } from "../../components/game/QuestionCard.js";
|
||||
import { MultiplayerScoreScreen } from "../../components/multiplayer/MultiplayerScoreScreen.js";
|
||||
import { GameRouteSearchSchema } from "@lila/shared";
|
||||
|
||||
import type {
|
||||
WsGameQuestion,
|
||||
WsGameAnswerResult,
|
||||
|
|
@ -12,8 +11,6 @@ import type {
|
|||
WsError,
|
||||
} from "@lila/shared";
|
||||
|
||||
const API_URL = (import.meta.env["VITE_API_URL"] as string) || "";
|
||||
|
||||
export const Route = createFileRoute("/multiplayer/game/$code")({
|
||||
component: GamePage,
|
||||
validateSearch: GameRouteSearchSchema,
|
||||
|
|
@ -25,7 +22,7 @@ function GamePage() {
|
|||
const { session } = Route.useRouteContext();
|
||||
const currentUserId = session.user.id;
|
||||
const client = useWsClient();
|
||||
const connect = useWsConnect();
|
||||
const isConnected = useWsConnected();
|
||||
|
||||
const [currentQuestion, setCurrentQuestion] = useState<WsGameQuestion | null>(
|
||||
null,
|
||||
|
|
@ -36,7 +33,6 @@ function GamePage() {
|
|||
const [gameFinished, setGameFinished] = useState<WsGameFinished | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasAnswered, setHasAnswered] = useState(false);
|
||||
const isConnectedRef = useRef(false);
|
||||
|
||||
const handleGameQuestion = useCallback((msg: WsGameQuestion) => {
|
||||
setCurrentQuestion(msg);
|
||||
|
|
@ -58,25 +54,14 @@ function GamePage() {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected) return;
|
||||
|
||||
client.on("game:question", handleGameQuestion);
|
||||
client.on("game:answer_result", handleAnswerResult);
|
||||
client.on("game:finished", handleGameFinished);
|
||||
client.on("error", handleWsError);
|
||||
|
||||
if (!client.isConnected()) {
|
||||
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;
|
||||
}
|
||||
client.send({ type: "game:ready", lobbyId });
|
||||
|
||||
return () => {
|
||||
client.off("game:question", handleGameQuestion);
|
||||
|
|
@ -84,10 +69,8 @@ function GamePage() {
|
|||
client.off("game:finished", handleGameFinished);
|
||||
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
|
||||
}, []);
|
||||
}, [isConnected]);
|
||||
|
||||
const handleAnswer = useCallback(
|
||||
(optionId: number) => {
|
||||
|
|
@ -116,11 +99,11 @@ function GamePage() {
|
|||
}
|
||||
|
||||
// Phase: loading
|
||||
if (!currentQuestion) {
|
||||
if (!isConnected || !currentQuestion) {
|
||||
return (
|
||||
<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">
|
||||
{error ?? "Loading game..."}
|
||||
{error ?? (isConnected ? "Loading game..." : "Connecting...")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
useWsClient,
|
||||
useWsConnect,
|
||||
useWsDisconnect,
|
||||
} from "../../lib/ws-hooks.js";
|
||||
import { useWsClient, useWsConnected } from "../../lib/ws-hooks.js";
|
||||
import type {
|
||||
Lobby,
|
||||
WsLobbyState,
|
||||
|
|
@ -12,8 +8,6 @@ import type {
|
|||
WsGameQuestion,
|
||||
} from "@lila/shared";
|
||||
|
||||
const API_URL = (import.meta.env["VITE_API_URL"] as string) || "";
|
||||
|
||||
export const Route = createFileRoute("/multiplayer/lobby/$code")({
|
||||
component: LobbyPage,
|
||||
});
|
||||
|
|
@ -24,13 +18,11 @@ function LobbyPage() {
|
|||
const currentUserId = session.user.id;
|
||||
const navigate = useNavigate();
|
||||
const client = useWsClient();
|
||||
const connect = useWsConnect();
|
||||
const disconnect = useWsDisconnect();
|
||||
const isConnected = useWsConnected();
|
||||
|
||||
const [lobby, setLobby] = useState<Lobby | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
|
||||
const lobbyIdRef = useRef<string | null>(null);
|
||||
|
||||
const handleLobbyState = useCallback((msg: WsLobbyState) => {
|
||||
|
|
@ -56,31 +48,24 @@ function LobbyPage() {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isConnected) return;
|
||||
|
||||
client.on("lobby:state", handleLobbyState);
|
||||
client.on("game:question", handleGameQuestion);
|
||||
client.on("error", handleWsError);
|
||||
|
||||
void connect(API_URL)
|
||||
.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.");
|
||||
});
|
||||
client.send({ type: "lobby:join", code });
|
||||
|
||||
return () => {
|
||||
client.off("lobby:state", handleLobbyState);
|
||||
client.off("game:question", handleGameQuestion);
|
||||
client.off("error", handleWsError);
|
||||
client.send({ type: "lobby:leave", lobbyId: lobby?.id ?? "" });
|
||||
disconnect();
|
||||
if (lobbyIdRef.current) {
|
||||
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
|
||||
}, []);
|
||||
}, [isConnected]);
|
||||
|
||||
const handleStart = useCallback(() => {
|
||||
if (!lobby) return;
|
||||
|
|
@ -88,11 +73,11 @@ function LobbyPage() {
|
|||
client.send({ type: "lobby:start", lobbyId: lobby.id });
|
||||
}, [lobby, client]);
|
||||
|
||||
if (!lobby) {
|
||||
if (!isConnected || !lobby) {
|
||||
return (
|
||||
<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">
|
||||
{error ?? "Connecting..."}
|
||||
{error ?? (isConnected ? "Joining lobby..." : "Connecting...")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue