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
188 lines
6 KiB
TypeScript
188 lines
6 KiB
TypeScript
import { createFileRoute } from "@tanstack/react-router";
|
|
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,
|
|
WsGameFinished,
|
|
WsError,
|
|
} from "@lila/shared";
|
|
|
|
export const Route = createFileRoute("/multiplayer/game/$code")({
|
|
component: GamePage,
|
|
validateSearch: GameRouteSearchSchema,
|
|
});
|
|
|
|
function GamePage() {
|
|
const { code } = Route.useParams();
|
|
const { lobbyId } = Route.useSearch();
|
|
const { session } = Route.useRouteContext();
|
|
const currentUserId = session.user.id;
|
|
const client = useWsClient();
|
|
const isConnected = useWsConnected();
|
|
|
|
const [currentQuestion, setCurrentQuestion] = useState<WsGameQuestion | null>(
|
|
null,
|
|
);
|
|
const [answerResult, setAnswerResult] = useState<WsGameAnswerResult | null>(
|
|
null,
|
|
);
|
|
const [gameFinished, setGameFinished] = useState<WsGameFinished | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [hasAnswered, setHasAnswered] = useState(false);
|
|
|
|
const handleGameQuestion = useCallback((msg: WsGameQuestion) => {
|
|
setCurrentQuestion(msg);
|
|
setAnswerResult(null);
|
|
setHasAnswered(false);
|
|
setError(null);
|
|
}, []);
|
|
|
|
const handleAnswerResult = useCallback((msg: WsGameAnswerResult) => {
|
|
setAnswerResult(msg);
|
|
}, []);
|
|
|
|
const handleGameFinished = useCallback((msg: WsGameFinished) => {
|
|
setGameFinished(msg);
|
|
}, []);
|
|
|
|
const handleWsError = useCallback((msg: WsError) => {
|
|
setError(msg.message);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!isConnected) return;
|
|
|
|
client.on("game:question", handleGameQuestion);
|
|
client.on("game:answer_result", handleAnswerResult);
|
|
client.on("game:finished", handleGameFinished);
|
|
client.on("error", handleWsError);
|
|
|
|
client.send({ type: "game:ready", lobbyId });
|
|
|
|
return () => {
|
|
client.off("game:question", handleGameQuestion);
|
|
client.off("game:answer_result", handleAnswerResult);
|
|
client.off("game:finished", handleGameFinished);
|
|
client.off("error", handleWsError);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [isConnected]);
|
|
|
|
const handleAnswer = useCallback(
|
|
(optionId: number) => {
|
|
if (hasAnswered || !currentQuestion) return;
|
|
setHasAnswered(true);
|
|
client.send({
|
|
type: "game:answer",
|
|
lobbyId,
|
|
questionId: currentQuestion.question.questionId,
|
|
selectedOptionId: optionId,
|
|
});
|
|
},
|
|
[hasAnswered, currentQuestion, client, lobbyId],
|
|
);
|
|
|
|
// Phase: finished
|
|
if (gameFinished) {
|
|
return (
|
|
<MultiplayerScoreScreen
|
|
players={gameFinished.players}
|
|
winnerIds={gameFinished.winnerIds}
|
|
currentUserId={currentUserId}
|
|
lobbyCode={code}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Phase: loading
|
|
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 ?? (isConnected ? "Loading game..." : "Connecting...")}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Phase: playing
|
|
return (
|
|
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
|
|
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md flex flex-col gap-6">
|
|
{/* Progress */}
|
|
<p className="text-sm text-gray-500 text-center">
|
|
Question {currentQuestion.questionNumber} of{" "}
|
|
{currentQuestion.totalQuestions}
|
|
</p>
|
|
|
|
{/* Question */}
|
|
<QuestionCard
|
|
question={currentQuestion.question}
|
|
questionNumber={currentQuestion.questionNumber}
|
|
totalQuestions={currentQuestion.totalQuestions}
|
|
currentResult={
|
|
answerResult
|
|
? {
|
|
questionId: currentQuestion.question.questionId,
|
|
isCorrect:
|
|
answerResult.results.find((r) => r.userId === currentUserId)
|
|
?.isCorrect ?? false,
|
|
correctOptionId: answerResult.correctOptionId,
|
|
selectedOptionId:
|
|
answerResult.results.find((r) => r.userId === currentUserId)
|
|
?.selectedOptionId ?? 0,
|
|
}
|
|
: null
|
|
}
|
|
onAnswer={handleAnswer}
|
|
onNext={() => {
|
|
setAnswerResult(null);
|
|
}}
|
|
/>
|
|
|
|
{/* Error */}
|
|
{error && <p className="text-red-500 text-sm text-center">{error}</p>}
|
|
|
|
{/* Round results */}
|
|
{answerResult && (
|
|
<div className="flex flex-col gap-2">
|
|
<h3 className="text-sm font-semibold text-gray-700">
|
|
Round results
|
|
</h3>
|
|
{answerResult.players.map((player) => {
|
|
const result = answerResult.results.find(
|
|
(r) => r.userId === player.userId,
|
|
);
|
|
return (
|
|
<div
|
|
key={player.userId}
|
|
className="flex items-center justify-between text-sm"
|
|
>
|
|
<span className="text-gray-700">{player.user.name}</span>
|
|
<span
|
|
className={
|
|
result?.isCorrect
|
|
? "text-green-600 font-medium"
|
|
: "text-red-500"
|
|
}
|
|
>
|
|
{result?.selectedOptionId === null
|
|
? "Timed out"
|
|
: result?.isCorrect
|
|
? "✓ Correct"
|
|
: "✗ Wrong"}
|
|
</span>
|
|
<span className="text-gray-500">{player.score} pts</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|