lila/apps/web/src/routes/multiplayer/game.$code.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

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