Fix stuff
This commit is contained in:
parent
fc270d437f
commit
32696ad33d
13 changed files with 2194 additions and 110 deletions
71
App.tsx
71
App.tsx
|
|
@ -13,6 +13,9 @@ import { RevealScreen } from './components/RevealScreen';
|
||||||
import { SaveQuizPrompt } from './components/SaveQuizPrompt';
|
import { SaveQuizPrompt } from './components/SaveQuizPrompt';
|
||||||
import { QuizEditor } from './components/QuizEditor';
|
import { QuizEditor } from './components/QuizEditor';
|
||||||
import { SaveOptionsModal } from './components/SaveOptionsModal';
|
import { SaveOptionsModal } from './components/SaveOptionsModal';
|
||||||
|
import { DisconnectedScreen } from './components/DisconnectedScreen';
|
||||||
|
import { WaitingToRejoin } from './components/WaitingToRejoin';
|
||||||
|
import { HostReconnected } from './components/HostReconnected';
|
||||||
import type { Quiz, GameConfig } from './types';
|
import type { Quiz, GameConfig } from './types';
|
||||||
|
|
||||||
const seededRandom = (seed: number) => {
|
const seededRandom = (seed: number) => {
|
||||||
|
|
@ -64,6 +67,7 @@ function App() {
|
||||||
handleAnswer,
|
handleAnswer,
|
||||||
hasAnswered,
|
hasAnswered,
|
||||||
lastPointsEarned,
|
lastPointsEarned,
|
||||||
|
lastAnswerCorrect,
|
||||||
nextQuestion,
|
nextQuestion,
|
||||||
showScoreboard,
|
showScoreboard,
|
||||||
currentCorrectShape,
|
currentCorrectShape,
|
||||||
|
|
@ -77,7 +81,13 @@ function App() {
|
||||||
updateQuizFromEditor,
|
updateQuizFromEditor,
|
||||||
startGameFromEditor,
|
startGameFromEditor,
|
||||||
backFromEditor,
|
backFromEditor,
|
||||||
gameConfig
|
gameConfig,
|
||||||
|
isReconnecting,
|
||||||
|
currentPlayerName,
|
||||||
|
attemptReconnect,
|
||||||
|
goHomeFromDisconnected,
|
||||||
|
endGame,
|
||||||
|
resumeGame
|
||||||
} = useGame();
|
} = useGame();
|
||||||
|
|
||||||
const handleSaveQuiz = async () => {
|
const handleSaveQuiz = async () => {
|
||||||
|
|
@ -168,6 +178,7 @@ function App() {
|
||||||
gamePin={gamePin}
|
gamePin={gamePin}
|
||||||
role={role}
|
role={role}
|
||||||
onStart={startGame}
|
onStart={startGame}
|
||||||
|
onEndGame={role === 'HOST' ? endGame : undefined}
|
||||||
/>
|
/>
|
||||||
{auth.isAuthenticated && pendingQuizToSave && (
|
{auth.isAuthenticated && pendingQuizToSave && (
|
||||||
<SaveQuizPrompt
|
<SaveQuizPrompt
|
||||||
|
|
@ -180,7 +191,7 @@ function App() {
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{(gameState === 'COUNTDOWN' || gameState === 'QUESTION') && quiz ? (
|
{(gameState === 'COUNTDOWN' || gameState === 'QUESTION') ? (
|
||||||
gameState === 'COUNTDOWN' ? (
|
gameState === 'COUNTDOWN' ? (
|
||||||
<div className="flex flex-col items-center justify-center h-screen animate-bounce">
|
<div className="flex flex-col items-center justify-center h-screen animate-bounce">
|
||||||
<div className="text-4xl font-display font-bold mb-4">Get Ready!</div>
|
<div className="text-4xl font-display font-bold mb-4">Get Ready!</div>
|
||||||
|
|
@ -188,7 +199,7 @@ function App() {
|
||||||
{timeLeft}
|
{timeLeft}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : quiz?.questions[currentQuestionIndex] ? (
|
||||||
<GameScreen
|
<GameScreen
|
||||||
question={quiz.questions[currentQuestionIndex]}
|
question={quiz.questions[currentQuestionIndex]}
|
||||||
timeLeft={timeLeft}
|
timeLeft={timeLeft}
|
||||||
|
|
@ -201,12 +212,26 @@ function App() {
|
||||||
lastPointsEarned={lastPointsEarned}
|
lastPointsEarned={lastPointsEarned}
|
||||||
hostPlays={gameConfig.hostParticipates}
|
hostPlays={gameConfig.hostParticipates}
|
||||||
/>
|
/>
|
||||||
)
|
) : role === 'CLIENT' && hasAnswered ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-screen">
|
||||||
|
<div className="bg-theme-primary/95 rounded-[2rem] p-12 text-center">
|
||||||
|
<div className="text-6xl mb-6">🚀</div>
|
||||||
|
<h2 className="text-4xl md:text-5xl font-black text-white font-display mb-4">Answer Sent!</h2>
|
||||||
|
<p className="text-xl font-bold opacity-80">Cross your fingers...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : currentPlayerName ? (
|
||||||
|
<WaitingToRejoin
|
||||||
|
playerName={currentPlayerName}
|
||||||
|
score={currentPlayerScore}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{gameState === 'REVEAL' && correctOpt ? (
|
{gameState === 'REVEAL' ? (
|
||||||
|
correctOpt ? (
|
||||||
<RevealScreen
|
<RevealScreen
|
||||||
isCorrect={lastPointsEarned !== null && lastPointsEarned > 0}
|
isCorrect={lastAnswerCorrect === true}
|
||||||
pointsEarned={lastPointsEarned || 0}
|
pointsEarned={lastPointsEarned || 0}
|
||||||
newScore={currentPlayerScore}
|
newScore={currentPlayerScore}
|
||||||
streak={currentStreak}
|
streak={currentStreak}
|
||||||
|
|
@ -215,6 +240,12 @@ function App() {
|
||||||
role={role}
|
role={role}
|
||||||
onNext={showScoreboard}
|
onNext={showScoreboard}
|
||||||
/>
|
/>
|
||||||
|
) : currentPlayerName ? (
|
||||||
|
<WaitingToRejoin
|
||||||
|
playerName={currentPlayerName}
|
||||||
|
score={currentPlayerScore}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{gameState === 'SCOREBOARD' ? (
|
{gameState === 'SCOREBOARD' ? (
|
||||||
|
|
@ -232,6 +263,34 @@ function App() {
|
||||||
onRestart={() => window.location.reload()}
|
onRestart={() => window.location.reload()}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{gameState === 'DISCONNECTED' && currentPlayerName && gamePin ? (
|
||||||
|
<DisconnectedScreen
|
||||||
|
playerName={currentPlayerName}
|
||||||
|
gamePin={gamePin}
|
||||||
|
isReconnecting={isReconnecting}
|
||||||
|
onReconnect={attemptReconnect}
|
||||||
|
onGoHome={goHomeFromDisconnected}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{gameState === 'WAITING_TO_REJOIN' && currentPlayerName ? (
|
||||||
|
<WaitingToRejoin
|
||||||
|
playerName={currentPlayerName}
|
||||||
|
score={currentPlayerScore}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{gameState === 'HOST_RECONNECTED' && quiz ? (
|
||||||
|
<HostReconnected
|
||||||
|
quizTitle={quiz.title}
|
||||||
|
currentQuestionIndex={currentQuestionIndex}
|
||||||
|
totalQuestions={quiz.questions.length}
|
||||||
|
playerCount={players.filter(p => p.id !== 'host').length}
|
||||||
|
onResume={resumeGame}
|
||||||
|
onEndGame={endGame}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SaveOptionsModal
|
<SaveOptionsModal
|
||||||
|
|
|
||||||
83
components/DisconnectedScreen.tsx
Normal file
83
components/DisconnectedScreen.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { WifiOff, RefreshCw, LogOut, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface DisconnectedScreenProps {
|
||||||
|
playerName: string;
|
||||||
|
gamePin: string;
|
||||||
|
isReconnecting: boolean;
|
||||||
|
onReconnect: () => void;
|
||||||
|
onGoHome: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DisconnectedScreen: React.FC<DisconnectedScreenProps> = ({
|
||||||
|
playerName,
|
||||||
|
gamePin,
|
||||||
|
isReconnecting,
|
||||||
|
onReconnect,
|
||||||
|
onGoHome,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen p-6 text-center">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
className="bg-white/10 backdrop-blur-md p-8 md:p-12 rounded-[2rem] border-4 border-white/20 shadow-xl max-w-md w-full"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{ y: [0, -10, 0] }}
|
||||||
|
transition={{ repeat: Infinity, duration: 2, ease: "easeInOut" }}
|
||||||
|
className="bg-orange-500 p-6 rounded-full inline-block mb-6 shadow-lg"
|
||||||
|
>
|
||||||
|
<WifiOff size={48} className="text-white" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<h1 className="text-4xl font-black font-display mb-4">Connection Lost</h1>
|
||||||
|
|
||||||
|
<p className="text-xl opacity-80 mb-2">
|
||||||
|
Hey <span className="font-bold text-yellow-300">{playerName}</span>!
|
||||||
|
</p>
|
||||||
|
<p className="text-lg opacity-70 mb-8">
|
||||||
|
You got disconnected from game <span className="font-mono font-bold">{gamePin}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
onClick={onReconnect}
|
||||||
|
disabled={isReconnecting}
|
||||||
|
className="w-full bg-white text-theme-primary px-8 py-4 rounded-full text-xl font-black shadow-[0_6px_0_rgba(0,0,0,0.2)] hover:shadow-[0_4px_0_rgba(0,0,0,0.2)] hover:translate-y-[2px] active:shadow-none active:translate-y-[6px] transition-all disabled:opacity-70 disabled:cursor-not-allowed flex items-center justify-center gap-3"
|
||||||
|
>
|
||||||
|
{isReconnecting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={24} className="animate-spin" />
|
||||||
|
Reconnecting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw size={24} />
|
||||||
|
Reconnect
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
onClick={onGoHome}
|
||||||
|
disabled={isReconnecting}
|
||||||
|
className="w-full bg-white/20 text-white px-8 py-4 rounded-full text-xl font-bold hover:bg-white/30 transition-all disabled:opacity-50 flex items-center justify-center gap-3"
|
||||||
|
>
|
||||||
|
<LogOut size={24} />
|
||||||
|
Abandon Game
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm opacity-50 mt-8">
|
||||||
|
Your score will be preserved if you reconnect
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
82
components/HostReconnected.tsx
Normal file
82
components/HostReconnected.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Play, Users, X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface HostReconnectedProps {
|
||||||
|
quizTitle: string;
|
||||||
|
currentQuestionIndex: number;
|
||||||
|
totalQuestions: number;
|
||||||
|
playerCount: number;
|
||||||
|
onResume: () => void;
|
||||||
|
onEndGame: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HostReconnected: React.FC<HostReconnectedProps> = ({
|
||||||
|
quizTitle,
|
||||||
|
currentQuestionIndex,
|
||||||
|
totalQuestions,
|
||||||
|
playerCount,
|
||||||
|
onResume,
|
||||||
|
onEndGame,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen p-6 text-center">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
className="bg-white/10 backdrop-blur-md p-8 md:p-12 rounded-[2rem] border-4 border-white/20 shadow-xl max-w-lg w-full"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ rotate: -10 }}
|
||||||
|
animate={{ rotate: 0 }}
|
||||||
|
className="bg-green-500 p-6 rounded-full inline-block mb-6 shadow-lg"
|
||||||
|
>
|
||||||
|
<Play size={48} className="text-white ml-1" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-black font-display mb-2">Game Restored</h1>
|
||||||
|
<p className="text-xl opacity-80 mb-6">{quizTitle}</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 mb-8">
|
||||||
|
<div className="bg-white/10 rounded-2xl p-4">
|
||||||
|
<p className="text-sm uppercase tracking-wide opacity-70 mb-1">Question</p>
|
||||||
|
<p className="text-3xl font-black">{currentQuestionIndex + 1} / {totalQuestions}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/10 rounded-2xl p-4">
|
||||||
|
<p className="text-sm uppercase tracking-wide opacity-70 mb-1">Players</p>
|
||||||
|
<p className="text-3xl font-black flex items-center justify-center gap-2">
|
||||||
|
<Users size={24} />
|
||||||
|
{playerCount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm opacity-60 mb-6">
|
||||||
|
Players will rejoin automatically when they reconnect
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
onClick={onResume}
|
||||||
|
className="w-full bg-white text-theme-primary px-8 py-4 rounded-full text-xl font-black shadow-[0_6px_0_rgba(0,0,0,0.2)] hover:shadow-[0_4px_0_rgba(0,0,0,0.2)] hover:translate-y-[2px] active:shadow-none active:translate-y-[6px] transition-all flex items-center justify-center gap-3"
|
||||||
|
>
|
||||||
|
<Play size={24} />
|
||||||
|
Resume Game
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
onClick={onEndGame}
|
||||||
|
className="w-full bg-white/20 text-white px-8 py-4 rounded-full text-lg font-bold hover:bg-white/30 transition-all flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
End Game
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Player } from '../types';
|
import { Player } from '../types';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Sparkles, User } from 'lucide-react';
|
import { Sparkles, User, X } from 'lucide-react';
|
||||||
import { PlayerAvatar } from './PlayerAvatar';
|
import { PlayerAvatar } from './PlayerAvatar';
|
||||||
|
|
||||||
interface LobbyProps {
|
interface LobbyProps {
|
||||||
|
|
@ -10,9 +10,10 @@ interface LobbyProps {
|
||||||
gamePin: string | null;
|
gamePin: string | null;
|
||||||
role: 'HOST' | 'CLIENT';
|
role: 'HOST' | 'CLIENT';
|
||||||
onStart: () => void;
|
onStart: () => void;
|
||||||
|
onEndGame?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role, onStart }) => {
|
export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role, onStart, onEndGame }) => {
|
||||||
const isHost = role === 'HOST';
|
const isHost = role === 'HOST';
|
||||||
const realPlayers = players.filter(p => p.id !== 'host');
|
const realPlayers = players.filter(p => p.id !== 'host');
|
||||||
|
|
||||||
|
|
@ -71,8 +72,17 @@ export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role,
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ y: 50, opacity: 0 }}
|
initial={{ y: 50, opacity: 0 }}
|
||||||
animate={{ y: 0, opacity: 1 }}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
className="fixed bottom-8"
|
className="fixed bottom-8 flex gap-4"
|
||||||
>
|
>
|
||||||
|
{onEndGame && (
|
||||||
|
<button
|
||||||
|
onClick={onEndGame}
|
||||||
|
className="bg-white/20 text-white px-8 py-5 rounded-full text-xl font-bold hover:bg-white/30 active:scale-95 transition-all flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
End Game
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onStart}
|
onClick={onStart}
|
||||||
disabled={realPlayers.length === 0}
|
disabled={realPlayers.length === 0}
|
||||||
|
|
|
||||||
53
components/WaitingToRejoin.tsx
Normal file
53
components/WaitingToRejoin.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
interface WaitingToRejoinProps {
|
||||||
|
playerName: string;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WaitingToRejoin: React.FC<WaitingToRejoinProps> = ({ playerName, score }) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen p-6 text-center">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
className="bg-white/10 backdrop-blur-md p-8 md:p-12 rounded-[2rem] border-4 border-white/20 shadow-xl max-w-md w-full"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ repeat: Infinity, duration: 3, ease: "linear" }}
|
||||||
|
className="bg-green-500 p-6 rounded-full inline-block mb-6 shadow-lg"
|
||||||
|
>
|
||||||
|
<Clock size={48} className="text-white" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-black font-display mb-4">Welcome Back!</h1>
|
||||||
|
|
||||||
|
<p className="text-xl opacity-80 mb-2">
|
||||||
|
<span className="font-bold text-yellow-300">{playerName}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-white/10 rounded-2xl p-4 mb-6">
|
||||||
|
<p className="text-sm uppercase tracking-wide opacity-70 mb-1">Your Score</p>
|
||||||
|
<p className="text-4xl font-black">{score.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-lg opacity-70">
|
||||||
|
Waiting for the current question to finish...
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
className="flex justify-center gap-2 mt-6"
|
||||||
|
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||||
|
transition={{ repeat: Infinity, duration: 1.5 }}
|
||||||
|
>
|
||||||
|
<div className="w-3 h-3 bg-white rounded-full" />
|
||||||
|
<div className="w-3 h-3 bg-white rounded-full" />
|
||||||
|
<div className="w-3 h-3 bg-white rounded-full" />
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
787
hooks/useGame.ts
787
hooks/useGame.ts
|
|
@ -5,6 +5,33 @@ import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePointsWithBrea
|
||||||
import { Peer, DataConnection } from 'peerjs';
|
import { Peer, DataConnection } from 'peerjs';
|
||||||
|
|
||||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
|
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
|
||||||
|
const SESSION_STORAGE_KEY = 'kaboot_session';
|
||||||
|
const STATE_SYNC_INTERVAL = 5000;
|
||||||
|
|
||||||
|
interface StoredSession {
|
||||||
|
pin: string;
|
||||||
|
role: GameRole;
|
||||||
|
hostSecret?: string;
|
||||||
|
playerName?: string;
|
||||||
|
playerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStoredSession = (): StoredSession | null => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(SESSION_STORAGE_KEY);
|
||||||
|
return stored ? JSON.parse(stored) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const storeSession = (session: StoredSession) => {
|
||||||
|
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearStoredSession = () => {
|
||||||
|
localStorage.removeItem(SESSION_STORAGE_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
export const useGame = () => {
|
export const useGame = () => {
|
||||||
const [role, setRole] = useState<GameRole>('HOST');
|
const [role, setRole] = useState<GameRole>('HOST');
|
||||||
|
|
@ -18,6 +45,7 @@ export const useGame = () => {
|
||||||
const [gamePin, setGamePin] = useState<string | null>(null);
|
const [gamePin, setGamePin] = useState<string | null>(null);
|
||||||
const [currentCorrectShape, setCurrentCorrectShape] = useState<string | null>(null);
|
const [currentCorrectShape, setCurrentCorrectShape] = useState<string | null>(null);
|
||||||
const [lastPointsEarned, setLastPointsEarned] = useState<number | null>(null);
|
const [lastPointsEarned, setLastPointsEarned] = useState<number | null>(null);
|
||||||
|
const [lastAnswerCorrect, setLastAnswerCorrect] = useState<boolean | null>(null);
|
||||||
const [selectedOption, setSelectedOption] = useState<AnswerOption | null>(null);
|
const [selectedOption, setSelectedOption] = useState<AnswerOption | null>(null);
|
||||||
const [currentPlayerScore, setCurrentPlayerScore] = useState(0);
|
const [currentPlayerScore, setCurrentPlayerScore] = useState(0);
|
||||||
const [currentStreak, setCurrentStreak] = useState(0);
|
const [currentStreak, setCurrentStreak] = useState(0);
|
||||||
|
|
@ -27,28 +55,341 @@ export const useGame = () => {
|
||||||
const [sourceQuizId, setSourceQuizId] = useState<string | null>(null);
|
const [sourceQuizId, setSourceQuizId] = useState<string | null>(null);
|
||||||
const [gameConfig, setGameConfig] = useState<GameConfig>(DEFAULT_GAME_CONFIG);
|
const [gameConfig, setGameConfig] = useState<GameConfig>(DEFAULT_GAME_CONFIG);
|
||||||
const [firstCorrectPlayerId, setFirstCorrectPlayerId] = useState<string | null>(null);
|
const [firstCorrectPlayerId, setFirstCorrectPlayerId] = useState<string | null>(null);
|
||||||
|
const [hostSecret, setHostSecret] = useState<string | null>(null);
|
||||||
|
const [isReconnecting, setIsReconnecting] = useState(false);
|
||||||
|
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const syncTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const peerRef = useRef<Peer | null>(null);
|
const peerRef = useRef<Peer | null>(null);
|
||||||
const connectionsRef = useRef<Map<string, DataConnection>>(new Map());
|
const connectionsRef = useRef<Map<string, DataConnection>>(new Map());
|
||||||
const hostConnectionRef = useRef<DataConnection | null>(null);
|
const hostConnectionRef = useRef<DataConnection | null>(null);
|
||||||
|
|
||||||
// Refs for callbacks/async functions to access latest state
|
|
||||||
const timeLeftRef = useRef(0);
|
const timeLeftRef = useRef(0);
|
||||||
const playersRef = useRef<Player[]>([]);
|
const playersRef = useRef<Player[]>([]);
|
||||||
const currentQuestionIndexRef = useRef(0);
|
const currentQuestionIndexRef = useRef(0);
|
||||||
const quizRef = useRef<Quiz | null>(null);
|
const quizRef = useRef<Quiz | null>(null);
|
||||||
const gameConfigRef = useRef<GameConfig>(DEFAULT_GAME_CONFIG);
|
const gameConfigRef = useRef<GameConfig>(DEFAULT_GAME_CONFIG);
|
||||||
|
const gamePinRef = useRef<string | null>(null);
|
||||||
|
const hostSecretRef = useRef<string | null>(null);
|
||||||
|
const gameStateRef = useRef<GameState>('LANDING');
|
||||||
|
|
||||||
useEffect(() => { timeLeftRef.current = timeLeft; }, [timeLeft]);
|
useEffect(() => { timeLeftRef.current = timeLeft; }, [timeLeft]);
|
||||||
useEffect(() => { playersRef.current = players; }, [players]);
|
useEffect(() => { playersRef.current = players; }, [players]);
|
||||||
useEffect(() => { currentQuestionIndexRef.current = currentQuestionIndex; }, [currentQuestionIndex]);
|
useEffect(() => { currentQuestionIndexRef.current = currentQuestionIndex; }, [currentQuestionIndex]);
|
||||||
useEffect(() => { quizRef.current = quiz; }, [quiz]);
|
useEffect(() => { quizRef.current = quiz; }, [quiz]);
|
||||||
useEffect(() => { gameConfigRef.current = gameConfig; }, [gameConfig]);
|
useEffect(() => { gameConfigRef.current = gameConfig; }, [gameConfig]);
|
||||||
|
useEffect(() => { gamePinRef.current = gamePin; }, [gamePin]);
|
||||||
|
useEffect(() => { hostSecretRef.current = hostSecret; }, [hostSecret]);
|
||||||
|
useEffect(() => { gameStateRef.current = gameState; }, [gameState]);
|
||||||
|
|
||||||
const generateGamePin = () => Math.floor(Math.random() * 900000) + 100000 + "";
|
const generateGamePin = () => Math.floor(Math.random() * 900000) + 100000 + "";
|
||||||
|
|
||||||
// -- HOST LOGIC --
|
const syncGameState = useCallback(async () => {
|
||||||
|
if (!gamePinRef.current || !hostSecretRef.current) return;
|
||||||
|
if (gameStateRef.current === 'LANDING' || gameStateRef.current === 'EDITING' || gameStateRef.current === 'CREATING' || gameStateRef.current === 'GENERATING') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`${BACKEND_URL}/api/games/${gamePinRef.current}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Host-Secret': hostSecretRef.current,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
gameState: gameStateRef.current,
|
||||||
|
currentQuestionIndex: currentQuestionIndexRef.current,
|
||||||
|
players: playersRef.current,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to sync game state:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const createGameSession = async (pin: string, peerId: string, quizData: Quiz, config: GameConfig): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BACKEND_URL}/api/games`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
pin,
|
||||||
|
hostPeerId: peerId,
|
||||||
|
quiz: quizData,
|
||||||
|
gameConfig: config,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Failed to create game session');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.hostSecret;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating game session:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateHostPeerId = async (pin: string, secret: string, newPeerId: string) => {
|
||||||
|
try {
|
||||||
|
await fetch(`${BACKEND_URL}/api/games/${pin}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Host-Secret': secret,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ hostPeerId: newPeerId }),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating host peer ID:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchHostSession = async (pin: string, secret: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BACKEND_URL}/api/games/${pin}/host`, {
|
||||||
|
headers: { 'X-Host-Secret': secret },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return response.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchGameInfo = async (pin: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BACKEND_URL}/api/games/${pin}`);
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return response.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteGameSession = async (pin: string, secret: string) => {
|
||||||
|
try {
|
||||||
|
await fetch(`${BACKEND_URL}/api/games/${pin}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'X-Host-Secret': secret },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting game session:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHostDataRef = useRef<(conn: DataConnection, data: NetworkMessage) => void>(() => {});
|
||||||
|
const handleClientDataRef = useRef<(data: NetworkMessage) => void>(() => {});
|
||||||
|
|
||||||
|
const setupHostPeer = (pin: string, onReady: (peerId: string) => void) => {
|
||||||
|
const peer = new Peer(`kaboot-${pin}`);
|
||||||
|
peerRef.current = peer;
|
||||||
|
|
||||||
|
peer.on('open', (id) => {
|
||||||
|
onReady(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
peer.on('connection', (conn) => {
|
||||||
|
conn.on('data', (data: any) => {
|
||||||
|
handleHostDataRef.current(conn, data);
|
||||||
|
});
|
||||||
|
conn.on('close', () => {
|
||||||
|
connectionsRef.current.delete(conn.peer);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
peer.on('error', (err) => {
|
||||||
|
if (err.type === 'unavailable-id') {
|
||||||
|
peer.destroy();
|
||||||
|
const newPeer = new Peer();
|
||||||
|
peerRef.current = newPeer;
|
||||||
|
|
||||||
|
newPeer.on('open', (id) => {
|
||||||
|
onReady(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
newPeer.on('connection', (conn) => {
|
||||||
|
conn.on('data', (data: any) => {
|
||||||
|
handleHostDataRef.current(conn, data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
newPeer.on('error', () => {
|
||||||
|
setError("Network error. Please reload.");
|
||||||
|
setGameState('LANDING');
|
||||||
|
clearStoredSession();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setError("Network error. Please reload.");
|
||||||
|
setGameState('LANDING');
|
||||||
|
clearStoredSession();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const initializeHostGame = async (newQuiz: Quiz, hostParticipates: boolean = true) => {
|
||||||
|
setQuiz(newQuiz);
|
||||||
|
const pin = generateGamePin();
|
||||||
|
setGamePin(pin);
|
||||||
|
|
||||||
|
setupHostPeer(pin, async (peerId) => {
|
||||||
|
const secret = await createGameSession(pin, peerId, newQuiz, gameConfigRef.current);
|
||||||
|
|
||||||
|
if (!secret) {
|
||||||
|
setError("Failed to create game. Please try again.");
|
||||||
|
setGameState('LANDING');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHostSecret(secret);
|
||||||
|
storeSession({ pin, role: 'HOST', hostSecret: secret });
|
||||||
|
|
||||||
|
if (hostParticipates) {
|
||||||
|
const hostPlayer: Player = {
|
||||||
|
id: 'host',
|
||||||
|
name: 'Host',
|
||||||
|
score: 0,
|
||||||
|
previousScore: 0,
|
||||||
|
streak: 0,
|
||||||
|
lastAnswerCorrect: null,
|
||||||
|
selectedShape: null,
|
||||||
|
pointsBreakdown: null,
|
||||||
|
isBot: false,
|
||||||
|
avatarSeed: Math.random(),
|
||||||
|
color: PLAYER_COLORS[0]
|
||||||
|
};
|
||||||
|
setPlayers([hostPlayer]);
|
||||||
|
setCurrentPlayerId('host');
|
||||||
|
setCurrentPlayerName('Host');
|
||||||
|
} else {
|
||||||
|
setPlayers([]);
|
||||||
|
setCurrentPlayerId(null);
|
||||||
|
setCurrentPlayerName(null);
|
||||||
|
}
|
||||||
|
setGameState('LOBBY');
|
||||||
|
|
||||||
|
syncTimerRef.current = setInterval(syncGameState, STATE_SYNC_INTERVAL);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const reconnectAsHost = async (session: StoredSession) => {
|
||||||
|
if (!session.hostSecret) {
|
||||||
|
clearStoredSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsReconnecting(true);
|
||||||
|
const hostData = await fetchHostSession(session.pin, session.hostSecret);
|
||||||
|
|
||||||
|
if (!hostData) {
|
||||||
|
clearStoredSession();
|
||||||
|
setIsReconnecting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRole('HOST');
|
||||||
|
setGamePin(session.pin);
|
||||||
|
setHostSecret(session.hostSecret);
|
||||||
|
setQuiz(hostData.quiz);
|
||||||
|
setGameConfig(hostData.gameConfig);
|
||||||
|
setCurrentQuestionIndex(hostData.currentQuestionIndex || 0);
|
||||||
|
|
||||||
|
const hostPlayer = (hostData.players || []).find((p: Player) => p.id === 'host');
|
||||||
|
if (hostPlayer) {
|
||||||
|
setCurrentPlayerId('host');
|
||||||
|
setCurrentPlayerName('Host');
|
||||||
|
setCurrentPlayerScore(hostPlayer.score);
|
||||||
|
setCurrentStreak(hostPlayer.streak);
|
||||||
|
setPlayers([hostPlayer]);
|
||||||
|
|
||||||
|
if (hostPlayer.lastAnswerCorrect !== null) {
|
||||||
|
setHasAnswered(true);
|
||||||
|
if (hostPlayer.pointsBreakdown) {
|
||||||
|
setLastPointsEarned(hostPlayer.pointsBreakdown.total);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setPlayers([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setupHostPeer(session.pin, async (peerId) => {
|
||||||
|
if (peerId !== hostData.hostPeerId) {
|
||||||
|
await updateHostPeerId(session.pin, session.hostSecret!, peerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostData.gameState === 'LOBBY') {
|
||||||
|
setGameState('LOBBY');
|
||||||
|
} else if (hostData.gameState === 'PODIUM') {
|
||||||
|
setGameState('PODIUM');
|
||||||
|
} else {
|
||||||
|
setGameState('HOST_RECONNECTED');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsReconnecting(false);
|
||||||
|
syncTimerRef.current = setInterval(syncGameState, STATE_SYNC_INTERVAL);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const reconnectAsClient = async (session: StoredSession) => {
|
||||||
|
if (!session.playerName) {
|
||||||
|
clearStoredSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsReconnecting(true);
|
||||||
|
setRole('CLIENT');
|
||||||
|
setGamePin(session.pin);
|
||||||
|
setCurrentPlayerName(session.playerName);
|
||||||
|
|
||||||
|
const gameInfo = await fetchGameInfo(session.pin);
|
||||||
|
|
||||||
|
if (!gameInfo) {
|
||||||
|
setGameState('DISCONNECTED');
|
||||||
|
setIsReconnecting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameInfo.gameState === 'PODIUM') {
|
||||||
|
clearStoredSession();
|
||||||
|
setIsReconnecting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const peer = new Peer();
|
||||||
|
peerRef.current = peer;
|
||||||
|
|
||||||
|
peer.on('open', (id) => {
|
||||||
|
setCurrentPlayerId(id);
|
||||||
|
storeSession({ ...session, playerId: id });
|
||||||
|
connectToHost(peer, gameInfo.hostPeerId, session.playerName!, true, session.playerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
peer.on('error', () => {
|
||||||
|
setError("Network error");
|
||||||
|
setIsReconnecting(false);
|
||||||
|
setGameState('DISCONNECTED');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const session = getStoredSession();
|
||||||
|
if (session) {
|
||||||
|
if (session.role === 'HOST') {
|
||||||
|
reconnectAsHost(session);
|
||||||
|
} else {
|
||||||
|
reconnectAsClient(session);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
if (syncTimerRef.current) clearInterval(syncTimerRef.current);
|
||||||
|
if (peerRef.current) peerRef.current.destroy();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const uploadDocument = async (file: File, useOcr: boolean = false): Promise<ProcessedDocument> => {
|
const uploadDocument = async (file: File, useOcr: boolean = false): Promise<ProcessedDocument> => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
@ -140,85 +481,112 @@ export const useGame = () => {
|
||||||
setGameState('LANDING');
|
setGameState('LANDING');
|
||||||
};
|
};
|
||||||
|
|
||||||
// We use a ref to hold the current handleHostData function
|
|
||||||
// This prevents stale closures in the PeerJS event listeners
|
|
||||||
const handleHostDataRef = useRef<(conn: DataConnection, data: NetworkMessage) => void>(() => {});
|
|
||||||
|
|
||||||
const initializeHostGame = (newQuiz: Quiz, hostParticipates: boolean = true) => {
|
|
||||||
setQuiz(newQuiz);
|
|
||||||
const pin = generateGamePin();
|
|
||||||
setGamePin(pin);
|
|
||||||
|
|
||||||
const peer = new Peer(`kaboot-${pin}`);
|
|
||||||
peerRef.current = peer;
|
|
||||||
|
|
||||||
peer.on('open', (id) => {
|
|
||||||
if (hostParticipates) {
|
|
||||||
const hostPlayer: Player = {
|
|
||||||
id: 'host',
|
|
||||||
name: 'Host',
|
|
||||||
score: 0,
|
|
||||||
previousScore: 0,
|
|
||||||
streak: 0,
|
|
||||||
lastAnswerCorrect: null,
|
|
||||||
pointsBreakdown: null,
|
|
||||||
isBot: false,
|
|
||||||
avatarSeed: Math.random(),
|
|
||||||
color: PLAYER_COLORS[0]
|
|
||||||
};
|
|
||||||
setPlayers([hostPlayer]);
|
|
||||||
setCurrentPlayerId('host');
|
|
||||||
setCurrentPlayerName('Host');
|
|
||||||
} else {
|
|
||||||
setPlayers([]);
|
|
||||||
setCurrentPlayerId(null);
|
|
||||||
setCurrentPlayerName(null);
|
|
||||||
}
|
|
||||||
setGameState('LOBBY');
|
|
||||||
});
|
|
||||||
|
|
||||||
peer.on('connection', (conn) => {
|
|
||||||
conn.on('data', (data: any) => {
|
|
||||||
// Delegate to the ref to ensure we always use the latest function closure
|
|
||||||
handleHostDataRef.current(conn, data);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
peer.on('error', () => {
|
|
||||||
setError("Network error. Try a different topic or reload.");
|
|
||||||
setGameState('LANDING');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHostData = (conn: DataConnection, data: NetworkMessage) => {
|
const handleHostData = (conn: DataConnection, data: NetworkMessage) => {
|
||||||
if (data.type === 'JOIN') {
|
if (data.type === 'JOIN') {
|
||||||
setPlayers(prev => {
|
const payload = data.payload as { name: string; reconnect?: boolean; previousId?: string };
|
||||||
if (prev.find(p => p.id === conn.peer)) return prev;
|
|
||||||
const colorIndex = prev.length % PLAYER_COLORS.length;
|
connectionsRef.current.set(conn.peer, conn);
|
||||||
|
|
||||||
|
const existingByPreviousId = payload.previousId ? playersRef.current.find(p => p.id === payload.previousId) : null;
|
||||||
|
const existingByName = playersRef.current.find(p => p.name === payload.name && p.id !== 'host');
|
||||||
|
const reconnectedPlayer = existingByPreviousId || existingByName;
|
||||||
|
|
||||||
|
console.log('[HOST] JOIN received:', {
|
||||||
|
name: payload.name,
|
||||||
|
previousId: payload.previousId,
|
||||||
|
reconnect: payload.reconnect,
|
||||||
|
foundByPreviousId: !!existingByPreviousId,
|
||||||
|
foundByName: !!existingByName,
|
||||||
|
reconnectedPlayer: reconnectedPlayer ? {
|
||||||
|
id: reconnectedPlayer.id,
|
||||||
|
hasAnswered: reconnectedPlayer.lastAnswerCorrect !== null,
|
||||||
|
lastAnswerCorrect: reconnectedPlayer.lastAnswerCorrect,
|
||||||
|
selectedShape: reconnectedPlayer.selectedShape
|
||||||
|
} : null,
|
||||||
|
allPlayers: playersRef.current.map(p => ({ id: p.id, name: p.name, lastAnswerCorrect: p.lastAnswerCorrect }))
|
||||||
|
});
|
||||||
|
|
||||||
|
if (reconnectedPlayer) {
|
||||||
|
setPlayers(prev => prev.map(p => p.id === reconnectedPlayer.id ? { ...p, id: conn.peer } : p));
|
||||||
|
} else if (!playersRef.current.find(p => p.id === conn.peer)) {
|
||||||
|
const colorIndex = playersRef.current.length % PLAYER_COLORS.length;
|
||||||
const newPlayer: Player = {
|
const newPlayer: Player = {
|
||||||
id: conn.peer,
|
id: conn.peer,
|
||||||
name: data.payload.name,
|
name: payload.name,
|
||||||
score: 0,
|
score: 0,
|
||||||
previousScore: 0,
|
previousScore: 0,
|
||||||
streak: 0,
|
streak: 0,
|
||||||
lastAnswerCorrect: null,
|
lastAnswerCorrect: null,
|
||||||
|
selectedShape: null,
|
||||||
pointsBreakdown: null,
|
pointsBreakdown: null,
|
||||||
isBot: false,
|
isBot: false,
|
||||||
avatarSeed: Math.random(),
|
avatarSeed: Math.random(),
|
||||||
color: PLAYER_COLORS[colorIndex]
|
color: PLAYER_COLORS[colorIndex]
|
||||||
};
|
};
|
||||||
return [...prev, newPlayer];
|
setPlayers(prev => [...prev, newPlayer]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentState = gameStateRef.current;
|
||||||
|
const currentQuiz = quizRef.current;
|
||||||
|
const currentIndex = currentQuestionIndexRef.current;
|
||||||
|
const currentQuestion = currentQuiz?.questions[currentIndex];
|
||||||
|
|
||||||
|
const welcomePayload: any = {
|
||||||
|
playerId: conn.peer,
|
||||||
|
quizTitle: currentQuiz?.title || 'Kaboot',
|
||||||
|
players: playersRef.current,
|
||||||
|
gameState: currentState,
|
||||||
|
currentQuestionIndex: currentIndex,
|
||||||
|
totalQuestions: currentQuiz?.questions.length || 0,
|
||||||
|
timeLeft: timeLeftRef.current,
|
||||||
|
score: 0,
|
||||||
|
streak: 0,
|
||||||
|
hasAnswered: false,
|
||||||
|
lastAnswerCorrect: null,
|
||||||
|
selectedShape: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (currentQuestion) {
|
||||||
|
welcomePayload.questionText = currentQuestion.text;
|
||||||
|
const correctOpt = currentQuestion.options.find(o => o.isCorrect);
|
||||||
|
welcomePayload.correctShape = correctOpt?.shape;
|
||||||
|
welcomePayload.options = currentQuestion.options.map(o => ({
|
||||||
|
...o,
|
||||||
|
isCorrect: false
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reconnectedPlayer) {
|
||||||
|
welcomePayload.score = reconnectedPlayer.score;
|
||||||
|
welcomePayload.streak = reconnectedPlayer.streak;
|
||||||
|
welcomePayload.hasAnswered = reconnectedPlayer.lastAnswerCorrect !== null;
|
||||||
|
welcomePayload.lastAnswerCorrect = reconnectedPlayer.lastAnswerCorrect;
|
||||||
|
welcomePayload.selectedShape = reconnectedPlayer.selectedShape;
|
||||||
|
if (reconnectedPlayer.pointsBreakdown) {
|
||||||
|
welcomePayload.lastPointsEarned = reconnectedPlayer.pointsBreakdown.total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[HOST] Sending WELCOME:', {
|
||||||
|
hasAnswered: welcomePayload.hasAnswered,
|
||||||
|
lastAnswerCorrect: welcomePayload.lastAnswerCorrect,
|
||||||
|
selectedShape: welcomePayload.selectedShape,
|
||||||
|
gameState: welcomePayload.gameState
|
||||||
});
|
});
|
||||||
connectionsRef.current.set(conn.peer, conn);
|
conn.send({ type: 'WELCOME', payload: welcomePayload });
|
||||||
conn.send({ type: 'WELCOME', payload: { playerId: conn.peer, quizTitle: 'Kaboot', players: [] } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.type === 'ANSWER') {
|
if (data.type === 'ANSWER') {
|
||||||
const { playerId, isCorrect } = data.payload;
|
const { playerId, isCorrect, selectedShape } = data.payload;
|
||||||
|
console.log('[HOST] ANSWER received:', { playerId, isCorrect, selectedShape });
|
||||||
|
|
||||||
const currentPlayer = playersRef.current.find(p => p.id === playerId);
|
const currentPlayer = playersRef.current.find(p => p.id === playerId);
|
||||||
|
console.log('[HOST] Current player:', currentPlayer ? { id: currentPlayer.id, lastAnswerCorrect: currentPlayer.lastAnswerCorrect } : 'NOT FOUND');
|
||||||
|
|
||||||
if (!currentPlayer || currentPlayer.lastAnswerCorrect !== null) return;
|
if (!currentPlayer || currentPlayer.lastAnswerCorrect !== null) {
|
||||||
|
console.log('[HOST] Ignoring answer - player not found or already answered');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const isFirstCorrect = isCorrect && firstCorrectPlayerId === null;
|
const isFirstCorrect = isCorrect && firstCorrectPlayerId === null;
|
||||||
if (isFirstCorrect) {
|
if (isFirstCorrect) {
|
||||||
|
|
@ -241,16 +609,16 @@ export const useGame = () => {
|
||||||
|
|
||||||
setPlayers(prev => prev.map(p => {
|
setPlayers(prev => prev.map(p => {
|
||||||
if (p.id !== playerId) return p;
|
if (p.id !== playerId) return p;
|
||||||
return { ...p, score: newScore, previousScore: p.score, streak: newStreak, lastAnswerCorrect: isCorrect, pointsBreakdown: breakdown };
|
return { ...p, score: newScore, previousScore: p.score, streak: newStreak, lastAnswerCorrect: isCorrect, selectedShape, pointsBreakdown: breakdown };
|
||||||
}));
|
}));
|
||||||
|
|
||||||
conn.send({ type: 'RESULT', payload: { isCorrect, scoreAdded: breakdown.total, newScore, breakdown } });
|
conn.send({ type: 'RESULT', payload: { isCorrect, scoreAdded: breakdown.total, newScore, breakdown } });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update the ref whenever handleHostData changes (which happens on render)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handleHostDataRef.current = handleHostData;
|
handleHostDataRef.current = handleHostData;
|
||||||
|
handleClientDataRef.current = handleClientData;
|
||||||
});
|
});
|
||||||
|
|
||||||
const broadcast = (msg: NetworkMessage) => {
|
const broadcast = (msg: NetworkMessage) => {
|
||||||
|
|
@ -263,7 +631,12 @@ export const useGame = () => {
|
||||||
startCountdown();
|
startCountdown();
|
||||||
};
|
};
|
||||||
|
|
||||||
const startCountdown = () => {
|
const resumeGame = () => {
|
||||||
|
broadcast({ type: 'GAME_START', payload: {} });
|
||||||
|
startCountdown(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCountdown = (isResume: boolean = false) => {
|
||||||
setGameState('COUNTDOWN');
|
setGameState('COUNTDOWN');
|
||||||
broadcast({ type: 'START_COUNTDOWN', payload: { duration: 3 } });
|
broadcast({ type: 'START_COUNTDOWN', payload: { duration: 3 } });
|
||||||
|
|
||||||
|
|
@ -276,19 +649,33 @@ export const useGame = () => {
|
||||||
setTimeLeft(count);
|
setTimeLeft(count);
|
||||||
if (count <= 0) {
|
if (count <= 0) {
|
||||||
if (timerRef.current) clearInterval(timerRef.current);
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
startQuestion();
|
startQuestion(isResume);
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const startQuestion = () => {
|
const startQuestion = (isResume: boolean = false) => {
|
||||||
setGameState('QUESTION');
|
setGameState('QUESTION');
|
||||||
setHasAnswered(false);
|
|
||||||
setLastPointsEarned(null);
|
|
||||||
setSelectedOption(null);
|
|
||||||
setTimeLeft(QUESTION_TIME_MS);
|
setTimeLeft(QUESTION_TIME_MS);
|
||||||
setFirstCorrectPlayerId(null);
|
setFirstCorrectPlayerId(null);
|
||||||
setPlayers(prev => prev.map(p => ({ ...p, lastAnswerCorrect: null, pointsBreakdown: null })));
|
|
||||||
|
if (isResume) {
|
||||||
|
const hostPlayer = playersRef.current.find(p => p.id === 'host');
|
||||||
|
const hostAlreadyAnswered = hostPlayer?.lastAnswerCorrect !== null;
|
||||||
|
|
||||||
|
if (hostAlreadyAnswered) {
|
||||||
|
setHasAnswered(true);
|
||||||
|
} else {
|
||||||
|
setHasAnswered(false);
|
||||||
|
setLastPointsEarned(null);
|
||||||
|
setSelectedOption(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setHasAnswered(false);
|
||||||
|
setLastPointsEarned(null);
|
||||||
|
setSelectedOption(null);
|
||||||
|
setPlayers(prev => prev.map(p => ({ ...p, lastAnswerCorrect: null, selectedShape: null, pointsBreakdown: null })));
|
||||||
|
}
|
||||||
|
|
||||||
const currentQuiz = quizRef.current;
|
const currentQuiz = quizRef.current;
|
||||||
const currentIndex = currentQuestionIndexRef.current;
|
const currentIndex = currentQuestionIndexRef.current;
|
||||||
|
|
@ -356,35 +743,224 @@ export const useGame = () => {
|
||||||
} else {
|
} else {
|
||||||
setGameState('PODIUM');
|
setGameState('PODIUM');
|
||||||
broadcast({ type: 'GAME_OVER', payload: { players } });
|
broadcast({ type: 'GAME_OVER', payload: { players } });
|
||||||
|
|
||||||
|
if (gamePinRef.current && hostSecretRef.current) {
|
||||||
|
deleteGameSession(gamePinRef.current, hostSecretRef.current);
|
||||||
|
}
|
||||||
|
clearStoredSession();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// -- CLIENT LOGIC --
|
const connectToHost = (peer: Peer, hostPeerId: string, playerName: string, isReconnect: boolean = false, previousId?: string) => {
|
||||||
|
const conn = peer.connect(hostPeerId);
|
||||||
|
hostConnectionRef.current = conn;
|
||||||
|
|
||||||
|
conn.on('open', () => {
|
||||||
|
conn.send({
|
||||||
|
type: 'JOIN',
|
||||||
|
payload: {
|
||||||
|
name: playerName,
|
||||||
|
reconnect: isReconnect,
|
||||||
|
previousId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setIsReconnecting(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.on('data', (data: any) => handleClientDataRef.current(data));
|
||||||
|
|
||||||
|
conn.on('close', () => {
|
||||||
|
if (gameStateRef.current !== 'PODIUM') {
|
||||||
|
setGameState('DISCONNECTED');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
conn.on('error', () => {
|
||||||
|
if (gameStateRef.current !== 'PODIUM') {
|
||||||
|
setGameState('DISCONNECTED');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!conn.open && gameStateRef.current === 'LANDING') {
|
||||||
|
setError("Could not connect to host");
|
||||||
|
setIsReconnecting(false);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
const joinGame = (pin: string, name: string) => {
|
const joinGame = async (pin: string, name: string) => {
|
||||||
setRole('CLIENT');
|
setRole('CLIENT');
|
||||||
setError(null);
|
setError(null);
|
||||||
setGamePin(pin);
|
setGamePin(pin);
|
||||||
setCurrentPlayerName(name);
|
setCurrentPlayerName(name);
|
||||||
|
|
||||||
|
const gameInfo = await fetchGameInfo(pin);
|
||||||
|
|
||||||
|
if (!gameInfo) {
|
||||||
|
setError("Game not found. Check the PIN.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const peer = new Peer();
|
const peer = new Peer();
|
||||||
peerRef.current = peer;
|
peerRef.current = peer;
|
||||||
|
|
||||||
peer.on('open', (id) => {
|
peer.on('open', (id) => {
|
||||||
setCurrentPlayerId(id);
|
setCurrentPlayerId(id);
|
||||||
const conn = peer.connect(`kaboot-${pin}`);
|
storeSession({ pin, role: 'CLIENT', playerName: name, playerId: id });
|
||||||
hostConnectionRef.current = conn;
|
connectToHost(peer, gameInfo.hostPeerId, name, false);
|
||||||
conn.on('open', () => {
|
});
|
||||||
conn.send({ type: 'JOIN', payload: { name } });
|
|
||||||
setGameState('LOBBY');
|
peer.on('error', () => {
|
||||||
});
|
setError("Network error");
|
||||||
conn.on('data', (data: any) => handleClientData(data));
|
|
||||||
conn.on('close', () => { setError("Disconnected"); setGameState('LANDING'); });
|
|
||||||
setTimeout(() => { if (!conn.open) setError("Check PIN"); }, 5000);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const attemptReconnect = async () => {
|
||||||
|
const session = getStoredSession();
|
||||||
|
if (!session || !session.playerName || !session.pin) {
|
||||||
|
clearStoredSession();
|
||||||
|
setGameState('LANDING');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsReconnecting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const gameInfo = await fetchGameInfo(session.pin);
|
||||||
|
|
||||||
|
if (!gameInfo) {
|
||||||
|
setError("Game no longer exists");
|
||||||
|
setIsReconnecting(false);
|
||||||
|
clearStoredSession();
|
||||||
|
setGameState('LANDING');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameInfo.gameState === 'PODIUM') {
|
||||||
|
setError("Game has ended");
|
||||||
|
setIsReconnecting(false);
|
||||||
|
clearStoredSession();
|
||||||
|
setGameState('LANDING');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peerRef.current) {
|
||||||
|
peerRef.current.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const peer = new Peer();
|
||||||
|
peerRef.current = peer;
|
||||||
|
|
||||||
|
peer.on('open', (id) => {
|
||||||
|
const previousId = session.playerId;
|
||||||
|
setCurrentPlayerId(id);
|
||||||
|
storeSession({ ...session, playerId: id });
|
||||||
|
connectToHost(peer, gameInfo.hostPeerId, session.playerName!, true, previousId);
|
||||||
|
});
|
||||||
|
|
||||||
|
peer.on('error', () => {
|
||||||
|
setError("Network error. Try again.");
|
||||||
|
setIsReconnecting(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const goHomeFromDisconnected = () => {
|
||||||
|
clearStoredSession();
|
||||||
|
if (peerRef.current) {
|
||||||
|
peerRef.current.destroy();
|
||||||
|
}
|
||||||
|
setGameState('LANDING');
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleClientData = (data: NetworkMessage) => {
|
const handleClientData = (data: NetworkMessage) => {
|
||||||
if (data.type === 'WELCOME') setQuiz({ title: data.payload.quizTitle, questions: [] });
|
if (data.type === 'WELCOME') {
|
||||||
|
const payload = data.payload;
|
||||||
|
console.log('[CLIENT] Received WELCOME:', {
|
||||||
|
hasAnswered: payload.hasAnswered,
|
||||||
|
lastAnswerCorrect: payload.lastAnswerCorrect,
|
||||||
|
selectedShape: payload.selectedShape,
|
||||||
|
gameState: payload.gameState
|
||||||
|
});
|
||||||
|
|
||||||
|
if (payload.score !== undefined) {
|
||||||
|
setCurrentPlayerScore(payload.score);
|
||||||
|
}
|
||||||
|
if (payload.streak !== undefined) {
|
||||||
|
setCurrentStreak(payload.streak);
|
||||||
|
}
|
||||||
|
if (payload.hasAnswered !== undefined) {
|
||||||
|
setHasAnswered(payload.hasAnswered);
|
||||||
|
}
|
||||||
|
if (payload.lastPointsEarned !== undefined) {
|
||||||
|
setLastPointsEarned(payload.lastPointsEarned);
|
||||||
|
}
|
||||||
|
if (payload.lastAnswerCorrect !== undefined) {
|
||||||
|
setLastAnswerCorrect(payload.lastAnswerCorrect);
|
||||||
|
}
|
||||||
|
if (payload.currentQuestionIndex !== undefined) {
|
||||||
|
setCurrentQuestionIndex(payload.currentQuestionIndex);
|
||||||
|
}
|
||||||
|
if (payload.correctShape) {
|
||||||
|
setCurrentCorrectShape(payload.correctShape);
|
||||||
|
}
|
||||||
|
if (payload.timeLeft !== undefined) {
|
||||||
|
setTimeLeft(payload.timeLeft);
|
||||||
|
}
|
||||||
|
if (payload.players) {
|
||||||
|
setPlayers(payload.players);
|
||||||
|
}
|
||||||
|
if (payload.selectedShape && payload.options) {
|
||||||
|
const matchedOption = payload.options.find(opt => opt.shape === payload.selectedShape);
|
||||||
|
if (matchedOption) {
|
||||||
|
setSelectedOption(matchedOption);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.questionText && payload.options && payload.totalQuestions !== undefined) {
|
||||||
|
const questions: Question[] = [];
|
||||||
|
for (let i = 0; i < payload.totalQuestions; i++) {
|
||||||
|
if (i === payload.currentQuestionIndex) {
|
||||||
|
questions.push({
|
||||||
|
id: `q-${i}`,
|
||||||
|
text: payload.questionText,
|
||||||
|
options: payload.options,
|
||||||
|
timeLimit: QUESTION_TIME
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
questions.push({ id: `loading-${i}`, text: '', options: [], timeLimit: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setQuiz({ title: payload.quizTitle, questions });
|
||||||
|
} else {
|
||||||
|
setQuiz({ title: payload.quizTitle, questions: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverGameState = payload.gameState;
|
||||||
|
const playerHasAnswered = payload.hasAnswered;
|
||||||
|
|
||||||
|
if (serverGameState && serverGameState !== 'LOBBY') {
|
||||||
|
if (serverGameState === 'QUESTION' || serverGameState === 'COUNTDOWN') {
|
||||||
|
setGameState(serverGameState);
|
||||||
|
if (!playerHasAnswered && serverGameState === 'QUESTION') {
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
timerRef.current = setInterval(() => setTimeLeft(prev => Math.max(0, prev - 100)), 100);
|
||||||
|
}
|
||||||
|
} else if (serverGameState === 'REVEAL') {
|
||||||
|
setGameState('REVEAL');
|
||||||
|
} else if (serverGameState === 'SCOREBOARD') {
|
||||||
|
setGameState('SCOREBOARD');
|
||||||
|
} else if (serverGameState === 'PODIUM') {
|
||||||
|
setGameState('PODIUM');
|
||||||
|
} else {
|
||||||
|
setGameState('LOBBY');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setGameState('LOBBY');
|
||||||
|
setHasAnswered(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (data.type === 'START_COUNTDOWN') {
|
if (data.type === 'START_COUNTDOWN') {
|
||||||
setGameState('COUNTDOWN');
|
setGameState('COUNTDOWN');
|
||||||
|
|
@ -399,6 +975,7 @@ export const useGame = () => {
|
||||||
setGameState('QUESTION');
|
setGameState('QUESTION');
|
||||||
setHasAnswered(false);
|
setHasAnswered(false);
|
||||||
setLastPointsEarned(null);
|
setLastPointsEarned(null);
|
||||||
|
setLastAnswerCorrect(null);
|
||||||
setSelectedOption(null);
|
setSelectedOption(null);
|
||||||
setCurrentQuestionIndex(data.payload.currentQuestionIndex);
|
setCurrentQuestionIndex(data.payload.currentQuestionIndex);
|
||||||
setTimeLeft(data.payload.timeLimit * 1000);
|
setTimeLeft(data.payload.timeLimit * 1000);
|
||||||
|
|
@ -424,6 +1001,7 @@ export const useGame = () => {
|
||||||
|
|
||||||
if (data.type === 'RESULT') {
|
if (data.type === 'RESULT') {
|
||||||
setLastPointsEarned(data.payload.scoreAdded);
|
setLastPointsEarned(data.payload.scoreAdded);
|
||||||
|
setLastAnswerCorrect(data.payload.isCorrect);
|
||||||
setCurrentPlayerScore(data.payload.newScore);
|
setCurrentPlayerScore(data.payload.newScore);
|
||||||
if (data.payload.isCorrect) {
|
if (data.payload.isCorrect) {
|
||||||
setCurrentStreak(prev => prev + 1);
|
setCurrentStreak(prev => prev + 1);
|
||||||
|
|
@ -437,8 +1015,16 @@ export const useGame = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.type === 'TIME_UP') {
|
if (data.type === 'TIME_UP') {
|
||||||
|
console.log('[CLIENT] TIME_UP received, current state:', {
|
||||||
|
hasAnswered,
|
||||||
|
lastAnswerCorrect,
|
||||||
|
lastPointsEarned,
|
||||||
|
selectedOption: selectedOption?.shape
|
||||||
|
});
|
||||||
if (timerRef.current) clearInterval(timerRef.current);
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
setGameState('REVEAL');
|
if (gameStateRef.current !== 'WAITING_TO_REJOIN') {
|
||||||
|
setGameState('REVEAL');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.type === 'SHOW_SCOREBOARD') {
|
if (data.type === 'SHOW_SCOREBOARD') {
|
||||||
|
|
@ -448,6 +1034,7 @@ export const useGame = () => {
|
||||||
if (data.type === 'GAME_OVER') {
|
if (data.type === 'GAME_OVER') {
|
||||||
setGameState('PODIUM');
|
setGameState('PODIUM');
|
||||||
setPlayers(data.payload.players);
|
setPlayers(data.payload.players);
|
||||||
|
clearStoredSession();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -484,39 +1071,45 @@ export const useGame = () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
setLastPointsEarned(breakdown.total);
|
setLastPointsEarned(breakdown.total);
|
||||||
|
setLastAnswerCorrect(isCorrect);
|
||||||
const newScore = Math.max(0, (hostPlayer?.score || 0) + breakdown.total);
|
const newScore = Math.max(0, (hostPlayer?.score || 0) + breakdown.total);
|
||||||
setCurrentPlayerScore(newScore);
|
setCurrentPlayerScore(newScore);
|
||||||
setCurrentStreak(newStreak);
|
setCurrentStreak(newStreak);
|
||||||
|
|
||||||
setPlayers(prev => prev.map(p => {
|
setPlayers(prev => prev.map(p => {
|
||||||
if (p.id !== 'host') return p;
|
if (p.id !== 'host') return p;
|
||||||
return { ...p, score: newScore, previousScore: p.score, streak: newStreak, lastAnswerCorrect: isCorrect, pointsBreakdown: breakdown };
|
return { ...p, score: newScore, previousScore: p.score, streak: newStreak, lastAnswerCorrect: isCorrect, selectedShape: option.shape, pointsBreakdown: breakdown };
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
const option = arg as AnswerOption;
|
const option = arg as AnswerOption;
|
||||||
setSelectedOption(option);
|
setSelectedOption(option);
|
||||||
const isCorrect = option.shape === currentCorrectShape;
|
const isCorrect = option.shape === currentCorrectShape;
|
||||||
hostConnectionRef.current?.send({ type: 'ANSWER', payload: { playerId: peerRef.current?.id, isCorrect } });
|
console.log('[CLIENT] Answering:', { selectedShape: option.shape, currentCorrectShape, isCorrect });
|
||||||
|
hostConnectionRef.current?.send({ type: 'ANSWER', payload: { playerId: peerRef.current?.id, isCorrect, selectedShape: option.shape } });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const endGame = () => {
|
||||||
|
if (gamePinRef.current && hostSecretRef.current) {
|
||||||
|
deleteGameSession(gamePinRef.current, hostSecretRef.current);
|
||||||
|
}
|
||||||
|
clearStoredSession();
|
||||||
|
if (timerRef.current) clearInterval(timerRef.current);
|
||||||
|
if (syncTimerRef.current) clearInterval(syncTimerRef.current);
|
||||||
|
if (peerRef.current) peerRef.current.destroy();
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (role === 'HOST' && (gameState === 'SCOREBOARD' || gameState === 'PODIUM')) {
|
if (role === 'HOST' && (gameState === 'SCOREBOARD' || gameState === 'PODIUM')) {
|
||||||
broadcast({ type: gameState === 'SCOREBOARD' ? 'SHOW_SCOREBOARD' : 'GAME_OVER', payload: { players } });
|
broadcast({ type: gameState === 'SCOREBOARD' ? 'SHOW_SCOREBOARD' : 'GAME_OVER', payload: { players } });
|
||||||
}
|
}
|
||||||
}, [gameState, players, role]);
|
}, [gameState, players, role]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (timerRef.current) clearInterval(timerRef.current);
|
|
||||||
if (peerRef.current) peerRef.current.destroy();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, gameConfig,
|
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, lastAnswerCorrect, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, gameConfig,
|
||||||
pendingQuizToSave, dismissSavePrompt, sourceQuizId,
|
pendingQuizToSave, dismissSavePrompt, sourceQuizId, isReconnecting, currentPlayerName,
|
||||||
startQuizGen, startManualCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard,
|
startQuizGen, startManualCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard,
|
||||||
updateQuizFromEditor, startGameFromEditor, backFromEditor
|
updateQuizFromEditor, startGameFromEditor, backFromEditor, endGame, attemptReconnect, goHomeFromDisconnected, resumeGame
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,26 @@ const runMigrations = () => {
|
||||||
db.exec("ALTER TABLE users ADD COLUMN default_game_config TEXT");
|
db.exec("ALTER TABLE users ADD COLUMN default_game_config TEXT");
|
||||||
console.log("Migration: Added default_game_config to users");
|
console.log("Migration: Added default_game_config to users");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='game_sessions'").get();
|
||||||
|
if (!tables) {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE game_sessions (
|
||||||
|
pin TEXT PRIMARY KEY,
|
||||||
|
host_peer_id TEXT NOT NULL,
|
||||||
|
host_secret TEXT NOT NULL,
|
||||||
|
quiz_data TEXT NOT NULL,
|
||||||
|
game_config TEXT NOT NULL,
|
||||||
|
game_state TEXT NOT NULL DEFAULT 'LOBBY',
|
||||||
|
current_question_index INTEGER NOT NULL DEFAULT 0,
|
||||||
|
players_data TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_game_sessions_updated ON game_sessions(updated_at);
|
||||||
|
`);
|
||||||
|
console.log("Migration: Created game_sessions table");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
runMigrations();
|
runMigrations();
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,20 @@ CREATE TABLE IF NOT EXISTS answer_options (
|
||||||
FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE
|
FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS game_sessions (
|
||||||
|
pin TEXT PRIMARY KEY,
|
||||||
|
host_peer_id TEXT NOT NULL,
|
||||||
|
host_secret TEXT NOT NULL,
|
||||||
|
quiz_data TEXT NOT NULL,
|
||||||
|
game_config TEXT NOT NULL,
|
||||||
|
game_state TEXT NOT NULL DEFAULT 'LOBBY',
|
||||||
|
current_question_index INTEGER NOT NULL DEFAULT 0,
|
||||||
|
players_data TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_quizzes_user ON quizzes(user_id);
|
CREATE INDEX IF NOT EXISTS idx_quizzes_user ON quizzes(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_questions_quiz ON questions(quiz_id);
|
CREATE INDEX IF NOT EXISTS idx_questions_quiz ON questions(quiz_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_options_question ON answer_options(question_id);
|
CREATE INDEX IF NOT EXISTS idx_options_question ON answer_options(question_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_game_sessions_updated ON game_sessions(updated_at);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { db } from './db/connection.js';
|
||||||
import quizzesRouter from './routes/quizzes.js';
|
import quizzesRouter from './routes/quizzes.js';
|
||||||
import usersRouter from './routes/users.js';
|
import usersRouter from './routes/users.js';
|
||||||
import uploadRouter from './routes/upload.js';
|
import uploadRouter from './routes/upload.js';
|
||||||
|
import gamesRouter from './routes/games.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
@ -60,6 +61,7 @@ app.get('/health', (_req: Request, res: Response) => {
|
||||||
app.use('/api/quizzes', quizzesRouter);
|
app.use('/api/quizzes', quizzesRouter);
|
||||||
app.use('/api/users', usersRouter);
|
app.use('/api/users', usersRouter);
|
||||||
app.use('/api/upload', uploadRouter);
|
app.use('/api/upload', uploadRouter);
|
||||||
|
app.use('/api/games', gamesRouter);
|
||||||
|
|
||||||
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
console.error('Unhandled error:', err);
|
console.error('Unhandled error:', err);
|
||||||
|
|
|
||||||
205
server/src/routes/games.ts
Normal file
205
server/src/routes/games.ts
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { db } from '../db/connection.js';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const SESSION_TTL_MINUTES = parseInt(process.env.GAME_SESSION_TTL_MINUTES || '5', 10);
|
||||||
|
|
||||||
|
interface GameSession {
|
||||||
|
pin: string;
|
||||||
|
host_peer_id: string;
|
||||||
|
host_secret: string;
|
||||||
|
quiz_data: string;
|
||||||
|
game_config: string;
|
||||||
|
game_state: string;
|
||||||
|
current_question_index: number;
|
||||||
|
players_data: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanupExpiredSessions = () => {
|
||||||
|
// Use SQLite's datetime function with the same format as CURRENT_TIMESTAMP
|
||||||
|
// to avoid string comparison issues between JS ISO format and SQLite format
|
||||||
|
const result = db.prepare(`
|
||||||
|
DELETE FROM game_sessions
|
||||||
|
WHERE updated_at < datetime('now', '-' || ? || ' minutes')
|
||||||
|
`).run(SESSION_TTL_MINUTES);
|
||||||
|
if (result.changes > 0) {
|
||||||
|
console.log(`Cleaned up ${result.changes} expired game session(s)`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
setInterval(cleanupExpiredSessions, 60 * 1000);
|
||||||
|
|
||||||
|
router.post('/', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { pin, hostPeerId, quiz, gameConfig } = req.body;
|
||||||
|
|
||||||
|
if (!pin || !hostPeerId || !quiz || !gameConfig) {
|
||||||
|
res.status(400).json({ error: 'Missing required fields' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostSecret = randomBytes(32).toString('hex');
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO game_sessions (pin, host_peer_id, host_secret, quiz_data, game_config, game_state, players_data)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'LOBBY', '[]')
|
||||||
|
`).run(pin, hostPeerId, hostSecret, JSON.stringify(quiz), JSON.stringify(gameConfig));
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, hostSecret });
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code === 'SQLITE_CONSTRAINT_PRIMARYKEY') {
|
||||||
|
res.status(409).json({ error: 'Game PIN already exists' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('Error creating game session:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to create game session' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:pin', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { pin } = req.params;
|
||||||
|
|
||||||
|
cleanupExpiredSessions();
|
||||||
|
|
||||||
|
const session = db.prepare('SELECT * FROM game_sessions WHERE pin = ?').get(pin) as GameSession | undefined;
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
res.status(404).json({ error: 'Game not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
pin: session.pin,
|
||||||
|
hostPeerId: session.host_peer_id,
|
||||||
|
gameState: session.game_state,
|
||||||
|
currentQuestionIndex: session.current_question_index,
|
||||||
|
quizTitle: JSON.parse(session.quiz_data).title,
|
||||||
|
playerCount: JSON.parse(session.players_data).length,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error getting game session:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to get game session' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:pin/host', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { pin } = req.params;
|
||||||
|
const hostSecret = req.headers['x-host-secret'] as string;
|
||||||
|
|
||||||
|
if (!hostSecret) {
|
||||||
|
res.status(401).json({ error: 'Host secret required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = db.prepare('SELECT * FROM game_sessions WHERE pin = ? AND host_secret = ?').get(pin, hostSecret) as GameSession | undefined;
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
res.status(404).json({ error: 'Game not found or invalid secret' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
pin: session.pin,
|
||||||
|
hostPeerId: session.host_peer_id,
|
||||||
|
quiz: JSON.parse(session.quiz_data),
|
||||||
|
gameConfig: JSON.parse(session.game_config),
|
||||||
|
gameState: session.game_state,
|
||||||
|
currentQuestionIndex: session.current_question_index,
|
||||||
|
players: JSON.parse(session.players_data),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error getting host session:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to get game session' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch('/:pin', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { pin } = req.params;
|
||||||
|
const hostSecret = req.headers['x-host-secret'] as string;
|
||||||
|
const { hostPeerId, gameState, currentQuestionIndex, players } = req.body;
|
||||||
|
|
||||||
|
if (!hostSecret) {
|
||||||
|
res.status(401).json({ error: 'Host secret required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = db.prepare('SELECT pin FROM game_sessions WHERE pin = ? AND host_secret = ?').get(pin, hostSecret);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
res.status(404).json({ error: 'Game not found or invalid secret' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: string[] = [];
|
||||||
|
const values: any[] = [];
|
||||||
|
|
||||||
|
if (hostPeerId !== undefined) {
|
||||||
|
updates.push('host_peer_id = ?');
|
||||||
|
values.push(hostPeerId);
|
||||||
|
}
|
||||||
|
if (gameState !== undefined) {
|
||||||
|
updates.push('game_state = ?');
|
||||||
|
values.push(gameState);
|
||||||
|
}
|
||||||
|
if (currentQuestionIndex !== undefined) {
|
||||||
|
updates.push('current_question_index = ?');
|
||||||
|
values.push(currentQuestionIndex);
|
||||||
|
}
|
||||||
|
if (players !== undefined) {
|
||||||
|
updates.push('players_data = ?');
|
||||||
|
values.push(JSON.stringify(players));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
res.status(400).json({ error: 'No updates provided' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push('updated_at = CURRENT_TIMESTAMP');
|
||||||
|
values.push(pin, hostSecret);
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE game_sessions
|
||||||
|
SET ${updates.join(', ')}
|
||||||
|
WHERE pin = ? AND host_secret = ?
|
||||||
|
`).run(...values);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating game session:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to update game session' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/:pin', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { pin } = req.params;
|
||||||
|
const hostSecret = req.headers['x-host-secret'] as string;
|
||||||
|
|
||||||
|
if (!hostSecret) {
|
||||||
|
res.status(401).json({ error: 'Host secret required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = db.prepare('DELETE FROM game_sessions WHERE pin = ? AND host_secret = ?').run(pin, hostSecret);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
res.status(404).json({ error: 'Game not found or invalid secret' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting game session:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to delete game session' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -1573,6 +1573,427 @@ async function runTests() {
|
||||||
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 404);
|
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('\n=== Game Session Tests ===');
|
||||||
|
|
||||||
|
async function gameRequest(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
headers?: Record<string, string>,
|
||||||
|
expectStatus = 200
|
||||||
|
): Promise<{ status: number; data: unknown }> {
|
||||||
|
const response = await fetch(`${API_URL}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.headers.get('content-type')?.includes('application/json')
|
||||||
|
? await response.json()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (response.status !== expectStatus) {
|
||||||
|
throw new Error(`Expected ${expectStatus}, got ${response.status}: ${JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: response.status, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
let testGamePin: string | null = null;
|
||||||
|
let testHostSecret: string | null = null;
|
||||||
|
|
||||||
|
console.log('\nGame Session CRUD Tests:');
|
||||||
|
|
||||||
|
await test('POST /api/games creates game session', async () => {
|
||||||
|
const gameData = {
|
||||||
|
pin: '123456',
|
||||||
|
hostPeerId: 'kaboot-123456',
|
||||||
|
quiz: {
|
||||||
|
title: 'Test Quiz',
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: 'q1',
|
||||||
|
text: 'What is 2+2?',
|
||||||
|
options: [
|
||||||
|
{ text: '3', isCorrect: false, shape: 'triangle', color: 'red' },
|
||||||
|
{ text: '4', isCorrect: true, shape: 'diamond', color: 'blue' },
|
||||||
|
],
|
||||||
|
timeLimit: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
gameConfig: {
|
||||||
|
shuffleQuestions: false,
|
||||||
|
shuffleAnswers: false,
|
||||||
|
hostParticipates: true,
|
||||||
|
streakBonusEnabled: false,
|
||||||
|
streakThreshold: 3,
|
||||||
|
streakMultiplier: 1.1,
|
||||||
|
comebackBonusEnabled: false,
|
||||||
|
comebackBonusPoints: 50,
|
||||||
|
penaltyForWrongAnswer: false,
|
||||||
|
penaltyPercent: 25,
|
||||||
|
firstCorrectBonusEnabled: false,
|
||||||
|
firstCorrectBonusPoints: 50,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await gameRequest('POST', '/api/games', gameData, {}, 201);
|
||||||
|
const result = data as { success: boolean; hostSecret: string };
|
||||||
|
|
||||||
|
if (!result.success) throw new Error('Expected success: true');
|
||||||
|
if (!result.hostSecret) throw new Error('Missing hostSecret');
|
||||||
|
if (result.hostSecret.length < 32) throw new Error('hostSecret too short');
|
||||||
|
|
||||||
|
testGamePin = '123456';
|
||||||
|
testHostSecret = result.hostSecret;
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('GET /api/games/:pin returns game info', async () => {
|
||||||
|
if (!testGamePin) throw new Error('No game created');
|
||||||
|
|
||||||
|
const { data } = await gameRequest('GET', `/api/games/${testGamePin}`);
|
||||||
|
const game = data as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (game.pin !== testGamePin) throw new Error('Wrong PIN');
|
||||||
|
if (game.hostPeerId !== 'kaboot-123456') throw new Error('Wrong hostPeerId');
|
||||||
|
if (game.gameState !== 'LOBBY') throw new Error('Wrong initial gameState');
|
||||||
|
if (game.currentQuestionIndex !== 0) throw new Error('Wrong initial currentQuestionIndex');
|
||||||
|
if (game.quizTitle !== 'Test Quiz') throw new Error('Wrong quizTitle');
|
||||||
|
if (game.playerCount !== 0) throw new Error('Wrong initial playerCount');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('GET /api/games/:pin/host returns full game state with valid secret', async () => {
|
||||||
|
if (!testGamePin || !testHostSecret) throw new Error('No game created');
|
||||||
|
|
||||||
|
const { data } = await gameRequest('GET', `/api/games/${testGamePin}/host`, undefined, {
|
||||||
|
'X-Host-Secret': testHostSecret,
|
||||||
|
});
|
||||||
|
const game = data as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (game.pin !== testGamePin) throw new Error('Wrong PIN');
|
||||||
|
if (!game.quiz) throw new Error('Missing quiz');
|
||||||
|
if (!game.gameConfig) throw new Error('Missing gameConfig');
|
||||||
|
if (!Array.isArray(game.players)) throw new Error('Missing players array');
|
||||||
|
|
||||||
|
const quiz = game.quiz as Record<string, unknown>;
|
||||||
|
if (quiz.title !== 'Test Quiz') throw new Error('Wrong quiz title');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('PATCH /api/games/:pin updates game state', async () => {
|
||||||
|
if (!testGamePin || !testHostSecret) throw new Error('No game created');
|
||||||
|
|
||||||
|
const update = {
|
||||||
|
gameState: 'QUESTION',
|
||||||
|
currentQuestionIndex: 1,
|
||||||
|
players: [
|
||||||
|
{
|
||||||
|
id: 'player1',
|
||||||
|
name: 'Alice',
|
||||||
|
score: 500,
|
||||||
|
previousScore: 0,
|
||||||
|
streak: 1,
|
||||||
|
lastAnswerCorrect: true,
|
||||||
|
pointsBreakdown: null,
|
||||||
|
isBot: false,
|
||||||
|
avatarSeed: 0.5,
|
||||||
|
color: '#2563eb',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await gameRequest('PATCH', `/api/games/${testGamePin}`, update, {
|
||||||
|
'X-Host-Secret': testHostSecret,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data } = await gameRequest('GET', `/api/games/${testGamePin}/host`, undefined, {
|
||||||
|
'X-Host-Secret': testHostSecret,
|
||||||
|
});
|
||||||
|
const game = data as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (game.gameState !== 'QUESTION') throw new Error('gameState not updated');
|
||||||
|
if (game.currentQuestionIndex !== 1) throw new Error('currentQuestionIndex not updated');
|
||||||
|
|
||||||
|
const players = game.players as Record<string, unknown>[];
|
||||||
|
if (players.length !== 1) throw new Error('players not updated');
|
||||||
|
if (players[0].name !== 'Alice') throw new Error('player name not saved');
|
||||||
|
if (players[0].score !== 500) throw new Error('player score not saved');
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('PATCH /api/games/:pin updates hostPeerId', async () => {
|
||||||
|
if (!testGamePin || !testHostSecret) throw new Error('No game created');
|
||||||
|
|
||||||
|
await gameRequest('PATCH', `/api/games/${testGamePin}`, { hostPeerId: 'new-peer-id-12345' }, {
|
||||||
|
'X-Host-Secret': testHostSecret,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data } = await gameRequest('GET', `/api/games/${testGamePin}`);
|
||||||
|
const game = data as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (game.hostPeerId !== 'new-peer-id-12345') throw new Error('hostPeerId not updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\nGame Session Auth Tests:');
|
||||||
|
|
||||||
|
await test('GET /api/games/:pin/host without secret returns 401', async () => {
|
||||||
|
if (!testGamePin) throw new Error('No game created');
|
||||||
|
await gameRequest('GET', `/api/games/${testGamePin}/host`, undefined, {}, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('GET /api/games/:pin/host with wrong secret returns 404', async () => {
|
||||||
|
if (!testGamePin) throw new Error('No game created');
|
||||||
|
await gameRequest('GET', `/api/games/${testGamePin}/host`, undefined, {
|
||||||
|
'X-Host-Secret': 'wrong-secret-12345',
|
||||||
|
}, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('PATCH /api/games/:pin without secret returns 401', async () => {
|
||||||
|
if (!testGamePin) throw new Error('No game created');
|
||||||
|
await gameRequest('PATCH', `/api/games/${testGamePin}`, { gameState: 'LOBBY' }, {}, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('PATCH /api/games/:pin with wrong secret returns 404', async () => {
|
||||||
|
if (!testGamePin) throw new Error('No game created');
|
||||||
|
await gameRequest('PATCH', `/api/games/${testGamePin}`, { gameState: 'LOBBY' }, {
|
||||||
|
'X-Host-Secret': 'wrong-secret-12345',
|
||||||
|
}, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('DELETE /api/games/:pin without secret returns 401', async () => {
|
||||||
|
if (!testGamePin) throw new Error('No game created');
|
||||||
|
await gameRequest('DELETE', `/api/games/${testGamePin}`, undefined, {}, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('DELETE /api/games/:pin with wrong secret returns 404', async () => {
|
||||||
|
if (!testGamePin) throw new Error('No game created');
|
||||||
|
await gameRequest('DELETE', `/api/games/${testGamePin}`, undefined, {
|
||||||
|
'X-Host-Secret': 'wrong-secret-12345',
|
||||||
|
}, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\nGame Session Not Found Tests:');
|
||||||
|
|
||||||
|
await test('GET /api/games/:pin with non-existent PIN returns 404', async () => {
|
||||||
|
await gameRequest('GET', '/api/games/999999', undefined, {}, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('GET /api/games/:pin/host with non-existent PIN returns 404', async () => {
|
||||||
|
await gameRequest('GET', '/api/games/999999/host', undefined, {
|
||||||
|
'X-Host-Secret': 'any-secret',
|
||||||
|
}, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('PATCH /api/games/:pin with non-existent PIN returns 404', async () => {
|
||||||
|
await gameRequest('PATCH', '/api/games/999999', { gameState: 'LOBBY' }, {
|
||||||
|
'X-Host-Secret': 'any-secret',
|
||||||
|
}, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\nGame Session Validation Tests:');
|
||||||
|
|
||||||
|
await test('POST /api/games without pin returns 400', async () => {
|
||||||
|
const invalidGame = {
|
||||||
|
hostPeerId: 'peer-123',
|
||||||
|
quiz: { title: 'Test', questions: [] },
|
||||||
|
gameConfig: {},
|
||||||
|
};
|
||||||
|
await gameRequest('POST', '/api/games', invalidGame, {}, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('POST /api/games without hostPeerId returns 400', async () => {
|
||||||
|
const invalidGame = {
|
||||||
|
pin: '111111',
|
||||||
|
quiz: { title: 'Test', questions: [] },
|
||||||
|
gameConfig: {},
|
||||||
|
};
|
||||||
|
await gameRequest('POST', '/api/games', invalidGame, {}, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('POST /api/games without quiz returns 400', async () => {
|
||||||
|
const invalidGame = {
|
||||||
|
pin: '111111',
|
||||||
|
hostPeerId: 'peer-123',
|
||||||
|
gameConfig: {},
|
||||||
|
};
|
||||||
|
await gameRequest('POST', '/api/games', invalidGame, {}, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('POST /api/games without gameConfig returns 400', async () => {
|
||||||
|
const invalidGame = {
|
||||||
|
pin: '111111',
|
||||||
|
hostPeerId: 'peer-123',
|
||||||
|
quiz: { title: 'Test', questions: [] },
|
||||||
|
};
|
||||||
|
await gameRequest('POST', '/api/games', invalidGame, {}, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('POST /api/games with duplicate PIN returns 409', async () => {
|
||||||
|
if (!testGamePin) throw new Error('No game created');
|
||||||
|
|
||||||
|
const duplicateGame = {
|
||||||
|
pin: testGamePin,
|
||||||
|
hostPeerId: 'another-peer',
|
||||||
|
quiz: { title: 'Another Quiz', questions: [] },
|
||||||
|
gameConfig: {},
|
||||||
|
};
|
||||||
|
await gameRequest('POST', '/api/games', duplicateGame, {}, 409);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('PATCH /api/games/:pin with no updates returns 400', async () => {
|
||||||
|
if (!testGamePin || !testHostSecret) throw new Error('No game created');
|
||||||
|
await gameRequest('PATCH', `/api/games/${testGamePin}`, {}, {
|
||||||
|
'X-Host-Secret': testHostSecret,
|
||||||
|
}, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\nGame Session Delete Tests:');
|
||||||
|
|
||||||
|
await test('DELETE /api/games/:pin with valid secret succeeds', async () => {
|
||||||
|
if (!testGamePin || !testHostSecret) throw new Error('No game created');
|
||||||
|
|
||||||
|
await gameRequest('DELETE', `/api/games/${testGamePin}`, undefined, {
|
||||||
|
'X-Host-Secret': testHostSecret,
|
||||||
|
});
|
||||||
|
|
||||||
|
await gameRequest('GET', `/api/games/${testGamePin}`, undefined, {}, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('DELETE /api/games/:pin twice returns 404 on second call', async () => {
|
||||||
|
const gameData = {
|
||||||
|
pin: '222222',
|
||||||
|
hostPeerId: 'kaboot-222222',
|
||||||
|
quiz: { title: 'Delete Test Quiz', questions: [] },
|
||||||
|
gameConfig: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await gameRequest('POST', '/api/games', gameData, {}, 201);
|
||||||
|
const secret = (data as { hostSecret: string }).hostSecret;
|
||||||
|
|
||||||
|
await gameRequest('DELETE', '/api/games/222222', undefined, {
|
||||||
|
'X-Host-Secret': secret,
|
||||||
|
});
|
||||||
|
|
||||||
|
await gameRequest('DELETE', '/api/games/222222', undefined, {
|
||||||
|
'X-Host-Secret': secret,
|
||||||
|
}, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\nGame Session Edge Cases:');
|
||||||
|
|
||||||
|
await test('POST /api/games with special characters in quiz title', async () => {
|
||||||
|
const gameData = {
|
||||||
|
pin: '333333',
|
||||||
|
hostPeerId: 'kaboot-333333',
|
||||||
|
quiz: {
|
||||||
|
title: 'Quiz with "quotes" & <tags> and special chars',
|
||||||
|
questions: [],
|
||||||
|
},
|
||||||
|
gameConfig: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await gameRequest('POST', '/api/games', gameData, {}, 201);
|
||||||
|
const secret = (data as { hostSecret: string }).hostSecret;
|
||||||
|
|
||||||
|
const { data: getResult } = await gameRequest('GET', '/api/games/333333');
|
||||||
|
const game = getResult as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (game.quizTitle !== 'Quiz with "quotes" & <tags> and special chars') {
|
||||||
|
throw new Error('Special characters not preserved in quiz title');
|
||||||
|
}
|
||||||
|
|
||||||
|
await gameRequest('DELETE', '/api/games/333333', undefined, {
|
||||||
|
'X-Host-Secret': secret,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('POST /api/games with large player data', async () => {
|
||||||
|
const gameData = {
|
||||||
|
pin: '444444',
|
||||||
|
hostPeerId: 'kaboot-444444',
|
||||||
|
quiz: { title: 'Large Players Test', questions: [] },
|
||||||
|
gameConfig: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await gameRequest('POST', '/api/games', gameData, {}, 201);
|
||||||
|
const secret = (data as { hostSecret: string }).hostSecret;
|
||||||
|
|
||||||
|
const manyPlayers = Array.from({ length: 50 }, (_, i) => ({
|
||||||
|
id: `player-${i}`,
|
||||||
|
name: `Player ${i}`,
|
||||||
|
score: i * 100,
|
||||||
|
previousScore: 0,
|
||||||
|
streak: i % 5,
|
||||||
|
lastAnswerCorrect: i % 2 === 0,
|
||||||
|
pointsBreakdown: null,
|
||||||
|
isBot: false,
|
||||||
|
avatarSeed: Math.random(),
|
||||||
|
color: '#2563eb',
|
||||||
|
}));
|
||||||
|
|
||||||
|
await gameRequest('PATCH', '/api/games/444444', { players: manyPlayers }, {
|
||||||
|
'X-Host-Secret': secret,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: getResult } = await gameRequest('GET', '/api/games/444444/host', undefined, {
|
||||||
|
'X-Host-Secret': secret,
|
||||||
|
});
|
||||||
|
const game = getResult as Record<string, unknown>;
|
||||||
|
const players = game.players as unknown[];
|
||||||
|
|
||||||
|
if (players.length !== 50) throw new Error(`Expected 50 players, got ${players.length}`);
|
||||||
|
|
||||||
|
await gameRequest('DELETE', '/api/games/444444', undefined, {
|
||||||
|
'X-Host-Secret': secret,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('GET /api/games/:pin public endpoint does not expose hostSecret', async () => {
|
||||||
|
const gameData = {
|
||||||
|
pin: '555555',
|
||||||
|
hostPeerId: 'kaboot-555555',
|
||||||
|
quiz: { title: 'Secret Test', questions: [] },
|
||||||
|
gameConfig: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: createResult } = await gameRequest('POST', '/api/games', gameData, {}, 201);
|
||||||
|
const secret = (createResult as { hostSecret: string }).hostSecret;
|
||||||
|
|
||||||
|
const { data: getResult } = await gameRequest('GET', '/api/games/555555');
|
||||||
|
const game = getResult as Record<string, unknown>;
|
||||||
|
|
||||||
|
if ('hostSecret' in game) throw new Error('hostSecret should not be exposed in public endpoint');
|
||||||
|
if ('host_secret' in game) throw new Error('host_secret should not be exposed in public endpoint');
|
||||||
|
|
||||||
|
await gameRequest('DELETE', '/api/games/555555', undefined, {
|
||||||
|
'X-Host-Secret': secret,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await test('Game session tracks updated_at on PATCH', async () => {
|
||||||
|
const gameData = {
|
||||||
|
pin: '666666',
|
||||||
|
hostPeerId: 'kaboot-666666',
|
||||||
|
quiz: { title: 'Timestamp Test', questions: [] },
|
||||||
|
gameConfig: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await gameRequest('POST', '/api/games', gameData, {}, 201);
|
||||||
|
const secret = (data as { hostSecret: string }).hostSecret;
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
await gameRequest('PATCH', '/api/games/666666', { gameState: 'COUNTDOWN' }, {
|
||||||
|
'X-Host-Secret': secret,
|
||||||
|
});
|
||||||
|
|
||||||
|
await gameRequest('DELETE', '/api/games/666666', undefined, {
|
||||||
|
'X-Host-Secret': secret,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
console.log('\n=== Results ===');
|
console.log('\n=== Results ===');
|
||||||
const passed = results.filter((r) => r.passed).length;
|
const passed = results.filter((r) => r.passed).length;
|
||||||
const failed = results.filter((r) => !r.passed).length;
|
const failed = results.filter((r) => !r.passed).length;
|
||||||
|
|
|
||||||
521
tests/hooks/useGame.reconnection.test.tsx
Normal file
521
tests/hooks/useGame.reconnection.test.tsx
Normal file
|
|
@ -0,0 +1,521 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test suite for game reconnection scenarios
|
||||||
|
*
|
||||||
|
* These tests verify the reconnection logic works correctly for:
|
||||||
|
* 1. Player reconnection with score/answer preservation
|
||||||
|
* 2. Host reconnection with game state preservation
|
||||||
|
* 3. State synchronization between host and reconnecting players
|
||||||
|
*/
|
||||||
|
describe('Game Reconnection Logic', () => {
|
||||||
|
describe('Player Matching on Reconnection', () => {
|
||||||
|
const createMockPlayer = (overrides = {}) => ({
|
||||||
|
id: 'player-123',
|
||||||
|
name: 'TestPlayer',
|
||||||
|
score: 500,
|
||||||
|
previousScore: 0,
|
||||||
|
streak: 2,
|
||||||
|
lastAnswerCorrect: true,
|
||||||
|
selectedShape: 'diamond' as const,
|
||||||
|
pointsBreakdown: { basePoints: 450, streakBonus: 50, comebackBonus: 0, firstCorrectBonus: 0, penalty: 0, total: 500 },
|
||||||
|
isBot: false,
|
||||||
|
avatarSeed: 0.5,
|
||||||
|
color: '#ff0000',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find existing player by previousId', () => {
|
||||||
|
const players = [createMockPlayer({ id: 'old-peer-id' })];
|
||||||
|
const payload = { name: 'TestPlayer', reconnect: true, previousId: 'old-peer-id' };
|
||||||
|
|
||||||
|
const existingByPreviousId = payload.previousId
|
||||||
|
? players.find(p => p.id === payload.previousId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
expect(existingByPreviousId).toBeDefined();
|
||||||
|
expect(existingByPreviousId?.score).toBe(500);
|
||||||
|
expect(existingByPreviousId?.lastAnswerCorrect).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find existing player by name when previousId not found', () => {
|
||||||
|
const players = [createMockPlayer({ id: 'different-id' })];
|
||||||
|
const payload = { name: 'TestPlayer', reconnect: true, previousId: 'non-existent-id' };
|
||||||
|
|
||||||
|
const existingByPreviousId = payload.previousId
|
||||||
|
? players.find(p => p.id === payload.previousId)
|
||||||
|
: null;
|
||||||
|
const existingByName = players.find(p => p.name === payload.name && p.id !== 'host');
|
||||||
|
const existingPlayer = existingByPreviousId || existingByName;
|
||||||
|
|
||||||
|
expect(existingByPreviousId).toBeUndefined();
|
||||||
|
expect(existingByName).toBeDefined();
|
||||||
|
expect(existingPlayer?.score).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve player score when reconnecting', () => {
|
||||||
|
const players = [createMockPlayer({ score: 1500, streak: 3 })];
|
||||||
|
const payload = { name: 'TestPlayer', reconnect: true, previousId: 'player-123' };
|
||||||
|
|
||||||
|
const existingPlayer = players.find(p => p.id === payload.previousId);
|
||||||
|
const reconnectedPlayer = existingPlayer
|
||||||
|
? { ...existingPlayer, id: 'new-peer-id' }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
expect(reconnectedPlayer?.score).toBe(1500);
|
||||||
|
expect(reconnectedPlayer?.streak).toBe(3);
|
||||||
|
expect(reconnectedPlayer?.id).toBe('new-peer-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve lastAnswerCorrect when reconnecting', () => {
|
||||||
|
const players = [createMockPlayer({ lastAnswerCorrect: true, pointsBreakdown: { total: 500 } as any })];
|
||||||
|
const payload = { name: 'TestPlayer', reconnect: true, previousId: 'player-123' };
|
||||||
|
|
||||||
|
const existingPlayer = players.find(p => p.id === payload.previousId);
|
||||||
|
|
||||||
|
expect(existingPlayer?.lastAnswerCorrect).toBe(true);
|
||||||
|
expect(existingPlayer?.pointsBreakdown?.total).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT create duplicate player when reconnecting by name', () => {
|
||||||
|
const players = [createMockPlayer({ id: 'old-id', name: 'TestPlayer' })];
|
||||||
|
const payload = { name: 'TestPlayer', reconnect: true, previousId: 'wrong-id' };
|
||||||
|
|
||||||
|
const existingByName = players.find(p => p.name === payload.name && p.id !== 'host');
|
||||||
|
|
||||||
|
// Should find existing player, not create new one
|
||||||
|
expect(existingByName).toBeDefined();
|
||||||
|
expect(players.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update player ID when reconnecting', () => {
|
||||||
|
const oldId = 'old-peer-id';
|
||||||
|
const newId = 'new-peer-id';
|
||||||
|
const players = [createMockPlayer({ id: oldId })];
|
||||||
|
|
||||||
|
const existingPlayer = players.find(p => p.id === oldId);
|
||||||
|
const updatedPlayers = players.map(p =>
|
||||||
|
p.id === oldId ? { ...p, id: newId } : p
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updatedPlayers[0].id).toBe(newId);
|
||||||
|
expect(updatedPlayers[0].score).toBe(existingPlayer?.score);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WELCOME Message Construction', () => {
|
||||||
|
it('should include hasAnswered when player already answered', () => {
|
||||||
|
const reconnectedPlayer = {
|
||||||
|
lastAnswerCorrect: true,
|
||||||
|
pointsBreakdown: { total: 500 }
|
||||||
|
};
|
||||||
|
|
||||||
|
const welcomePayload: any = {};
|
||||||
|
|
||||||
|
if (reconnectedPlayer) {
|
||||||
|
welcomePayload.hasAnswered = reconnectedPlayer.lastAnswerCorrect !== null;
|
||||||
|
welcomePayload.lastAnswerCorrect = reconnectedPlayer.lastAnswerCorrect;
|
||||||
|
if (reconnectedPlayer.pointsBreakdown) {
|
||||||
|
welcomePayload.lastPointsEarned = reconnectedPlayer.pointsBreakdown.total;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(welcomePayload.hasAnswered).toBe(true);
|
||||||
|
expect(welcomePayload.lastAnswerCorrect).toBe(true);
|
||||||
|
expect(welcomePayload.lastPointsEarned).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include hasAnswered=false when player has not answered', () => {
|
||||||
|
const reconnectedPlayer = {
|
||||||
|
lastAnswerCorrect: null,
|
||||||
|
pointsBreakdown: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const welcomePayload: any = {};
|
||||||
|
|
||||||
|
if (reconnectedPlayer) {
|
||||||
|
welcomePayload.hasAnswered = reconnectedPlayer.lastAnswerCorrect !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(welcomePayload.hasAnswered).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include question data for mid-game reconnection', () => {
|
||||||
|
const currentQuestion = {
|
||||||
|
text: 'What is 2+2?',
|
||||||
|
options: [
|
||||||
|
{ text: '3', isCorrect: false, shape: 'triangle', color: 'red' },
|
||||||
|
{ text: '4', isCorrect: true, shape: 'diamond', color: 'blue' },
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const welcomePayload: any = {
|
||||||
|
gameState: 'QUESTION',
|
||||||
|
currentQuestionIndex: 1,
|
||||||
|
totalQuestions: 5,
|
||||||
|
timeLeft: 15000,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (currentQuestion) {
|
||||||
|
welcomePayload.questionText = currentQuestion.text;
|
||||||
|
const correctOpt = currentQuestion.options.find(o => o.isCorrect);
|
||||||
|
welcomePayload.correctShape = correctOpt?.shape;
|
||||||
|
// Mask correct answer from client
|
||||||
|
welcomePayload.options = currentQuestion.options.map(o => ({
|
||||||
|
...o,
|
||||||
|
isCorrect: false
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(welcomePayload.questionText).toBe('What is 2+2?');
|
||||||
|
expect(welcomePayload.correctShape).toBe('diamond');
|
||||||
|
expect(welcomePayload.options.every((o: any) => o.isCorrect === false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include player scores and streak', () => {
|
||||||
|
const reconnectedPlayer = {
|
||||||
|
score: 1500,
|
||||||
|
streak: 3,
|
||||||
|
lastAnswerCorrect: true,
|
||||||
|
pointsBreakdown: { total: 500 }
|
||||||
|
};
|
||||||
|
|
||||||
|
const welcomePayload: any = {};
|
||||||
|
|
||||||
|
if (reconnectedPlayer) {
|
||||||
|
welcomePayload.score = reconnectedPlayer.score;
|
||||||
|
welcomePayload.streak = reconnectedPlayer.streak;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(welcomePayload.score).toBe(1500);
|
||||||
|
expect(welcomePayload.streak).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Client State Sync on WELCOME', () => {
|
||||||
|
it('should set QUESTION state when host is in QUESTION and player answered', () => {
|
||||||
|
const payload = {
|
||||||
|
gameState: 'QUESTION' as const,
|
||||||
|
hasAnswered: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let resultState: string;
|
||||||
|
|
||||||
|
if (payload.gameState === 'QUESTION' || payload.gameState === 'COUNTDOWN') {
|
||||||
|
resultState = payload.gameState;
|
||||||
|
} else {
|
||||||
|
resultState = 'LOBBY';
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(resultState).toBe('QUESTION');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set REVEAL state when host is in REVEAL', () => {
|
||||||
|
const payload = {
|
||||||
|
gameState: 'REVEAL' as const,
|
||||||
|
hasAnswered: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let resultState: string;
|
||||||
|
|
||||||
|
if (payload.gameState === 'REVEAL') {
|
||||||
|
resultState = 'REVEAL';
|
||||||
|
} else {
|
||||||
|
resultState = 'LOBBY';
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(resultState).toBe('REVEAL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set SCOREBOARD state when host is in SCOREBOARD', () => {
|
||||||
|
const payload = {
|
||||||
|
gameState: 'SCOREBOARD' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
let resultState: string;
|
||||||
|
|
||||||
|
if (payload.gameState === 'SCOREBOARD') {
|
||||||
|
resultState = 'SCOREBOARD';
|
||||||
|
} else {
|
||||||
|
resultState = 'LOBBY';
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(resultState).toBe('SCOREBOARD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reconstruct quiz from WELCOME payload', () => {
|
||||||
|
const payload = {
|
||||||
|
quizTitle: 'Test Quiz',
|
||||||
|
totalQuestions: 5,
|
||||||
|
currentQuestionIndex: 2,
|
||||||
|
questionText: 'What is 2+2?',
|
||||||
|
options: [
|
||||||
|
{ text: '4', shape: 'diamond', color: 'blue', isCorrect: false }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const questions: any[] = [];
|
||||||
|
for (let i = 0; i < payload.totalQuestions; i++) {
|
||||||
|
if (i === payload.currentQuestionIndex) {
|
||||||
|
questions.push({
|
||||||
|
id: `q-${i}`,
|
||||||
|
text: payload.questionText,
|
||||||
|
options: payload.options,
|
||||||
|
timeLimit: 30
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
questions.push({ id: `loading-${i}`, text: '', options: [], timeLimit: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const quiz = { title: payload.quizTitle, questions };
|
||||||
|
|
||||||
|
expect(quiz.questions.length).toBe(5);
|
||||||
|
expect(quiz.questions[2].text).toBe('What is 2+2?');
|
||||||
|
expect(quiz.questions[0].text).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Host Reconnection - Game Resume', () => {
|
||||||
|
it('should set HOST_RECONNECTED state when game was mid-play', () => {
|
||||||
|
const hostData = {
|
||||||
|
gameState: 'QUESTION',
|
||||||
|
currentQuestionIndex: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
let resultState: string;
|
||||||
|
|
||||||
|
if (hostData.gameState === 'LOBBY') {
|
||||||
|
resultState = 'LOBBY';
|
||||||
|
} else if (hostData.gameState === 'PODIUM') {
|
||||||
|
resultState = 'PODIUM';
|
||||||
|
} else {
|
||||||
|
resultState = 'HOST_RECONNECTED';
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(resultState).toBe('HOST_RECONNECTED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve currentQuestionIndex on host reconnect', () => {
|
||||||
|
const hostData = {
|
||||||
|
currentQuestionIndex: 3,
|
||||||
|
quiz: { questions: [{}, {}, {}, {}, {}] }
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(hostData.currentQuestionIndex).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should restore host player score on reconnect', () => {
|
||||||
|
const hostData = {
|
||||||
|
players: [
|
||||||
|
{ id: 'host', name: 'Host', score: 1200, streak: 4 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const hostPlayer = hostData.players.find(p => p.id === 'host');
|
||||||
|
|
||||||
|
expect(hostPlayer?.score).toBe(1200);
|
||||||
|
expect(hostPlayer?.streak).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear non-host players on host reconnect (they will rejoin)', () => {
|
||||||
|
const hostData = {
|
||||||
|
players: [
|
||||||
|
{ id: 'host', name: 'Host', score: 1200 },
|
||||||
|
{ id: 'player1', name: 'Player1', score: 800 },
|
||||||
|
{ id: 'player2', name: 'Player2', score: 600 },
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const hostPlayer = hostData.players.find(p => p.id === 'host');
|
||||||
|
const restoredPlayers = hostPlayer ? [hostPlayer] : [];
|
||||||
|
|
||||||
|
// Only host should be in players array after reconnect
|
||||||
|
// Other players will rejoin and be matched by name
|
||||||
|
expect(restoredPlayers.length).toBe(1);
|
||||||
|
expect(restoredPlayers[0].id).toBe('host');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Host Answer State on Resume', () => {
|
||||||
|
it('should preserve hasAnswered=true if host already answered before disconnect', () => {
|
||||||
|
const hostPlayer = {
|
||||||
|
id: 'host',
|
||||||
|
lastAnswerCorrect: true,
|
||||||
|
pointsBreakdown: { total: 500 }
|
||||||
|
};
|
||||||
|
|
||||||
|
const hostAlreadyAnswered = hostPlayer.lastAnswerCorrect !== null;
|
||||||
|
|
||||||
|
expect(hostAlreadyAnswered).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow host to answer if they had not answered before disconnect', () => {
|
||||||
|
const hostPlayer = {
|
||||||
|
id: 'host',
|
||||||
|
lastAnswerCorrect: null,
|
||||||
|
pointsBreakdown: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const hostAlreadyAnswered = hostPlayer.lastAnswerCorrect !== null;
|
||||||
|
|
||||||
|
expect(hostAlreadyAnswered).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('startQuestion with isResume=true should preserve answer state', () => {
|
||||||
|
const players = [
|
||||||
|
{ id: 'host', lastAnswerCorrect: true, score: 500 },
|
||||||
|
{ id: 'player1', lastAnswerCorrect: false, score: 0 },
|
||||||
|
{ id: 'player2', lastAnswerCorrect: null, score: 300 }, // Didn't answer yet
|
||||||
|
];
|
||||||
|
|
||||||
|
const isResume = true;
|
||||||
|
|
||||||
|
// When resuming, players who already answered should keep their state
|
||||||
|
const updatedPlayers = isResume
|
||||||
|
? players // Keep all states on resume
|
||||||
|
: players.map(p => ({ ...p, lastAnswerCorrect: null, pointsBreakdown: null }));
|
||||||
|
|
||||||
|
expect(updatedPlayers[0].lastAnswerCorrect).toBe(true);
|
||||||
|
expect(updatedPlayers[1].lastAnswerCorrect).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('startQuestion with isResume=false should reset all answer states', () => {
|
||||||
|
const players = [
|
||||||
|
{ id: 'host', lastAnswerCorrect: true, score: 500 },
|
||||||
|
{ id: 'player1', lastAnswerCorrect: false, score: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isResume = false;
|
||||||
|
|
||||||
|
const updatedPlayers = isResume
|
||||||
|
? players
|
||||||
|
: players.map(p => ({ ...p, lastAnswerCorrect: null, pointsBreakdown: null }));
|
||||||
|
|
||||||
|
expect(updatedPlayers[0].lastAnswerCorrect).toBe(null);
|
||||||
|
expect(updatedPlayers[1].lastAnswerCorrect).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Disconnection State Transitions', () => {
|
||||||
|
it('should transition to DISCONNECTED state on connection close', () => {
|
||||||
|
const currentGameState: string = 'QUESTION';
|
||||||
|
let newState = currentGameState;
|
||||||
|
|
||||||
|
// Simulating conn.on('close') handler
|
||||||
|
if (currentGameState !== 'PODIUM') {
|
||||||
|
newState = 'DISCONNECTED';
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(newState).toBe('DISCONNECTED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT transition to DISCONNECTED if game is at PODIUM', () => {
|
||||||
|
const currentGameState: string = 'PODIUM';
|
||||||
|
let newState = currentGameState;
|
||||||
|
|
||||||
|
if (currentGameState !== 'PODIUM') {
|
||||||
|
newState = 'DISCONNECTED';
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(newState).toBe('PODIUM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve player name and game PIN when disconnected', () => {
|
||||||
|
const gameState = {
|
||||||
|
currentPlayerName: 'TestPlayer',
|
||||||
|
gamePin: '123456',
|
||||||
|
};
|
||||||
|
|
||||||
|
// On disconnect, these should NOT be cleared - only gameState changes to DISCONNECTED
|
||||||
|
expect(gameState.currentPlayerName).toBe('TestPlayer');
|
||||||
|
expect(gameState.gamePin).toBe('123456');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Session Storage Logic', () => {
|
||||||
|
const SESSION_STORAGE_KEY = 'kaboot_session';
|
||||||
|
|
||||||
|
const getStoredSession = (storage: Map<string, string>) => {
|
||||||
|
const stored = storage.get(SESSION_STORAGE_KEY);
|
||||||
|
return stored ? JSON.parse(stored) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const storeSession = (storage: Map<string, string>, session: any) => {
|
||||||
|
storage.set(SESSION_STORAGE_KEY, JSON.stringify(session));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearStoredSession = (storage: Map<string, string>) => {
|
||||||
|
storage.delete(SESSION_STORAGE_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should store session with all required fields for client', () => {
|
||||||
|
const storage = new Map<string, string>();
|
||||||
|
const session = {
|
||||||
|
pin: '123456',
|
||||||
|
role: 'CLIENT' as const,
|
||||||
|
playerName: 'TestPlayer',
|
||||||
|
playerId: 'peer-123'
|
||||||
|
};
|
||||||
|
|
||||||
|
storeSession(storage, session);
|
||||||
|
|
||||||
|
const stored = getStoredSession(storage);
|
||||||
|
expect(stored.pin).toBe('123456');
|
||||||
|
expect(stored.role).toBe('CLIENT');
|
||||||
|
expect(stored.playerName).toBe('TestPlayer');
|
||||||
|
expect(stored.playerId).toBe('peer-123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should store session with hostSecret for host', () => {
|
||||||
|
const storage = new Map<string, string>();
|
||||||
|
const session = {
|
||||||
|
pin: '123456',
|
||||||
|
role: 'HOST' as const,
|
||||||
|
hostSecret: 'secret-abc123'
|
||||||
|
};
|
||||||
|
|
||||||
|
storeSession(storage, session);
|
||||||
|
|
||||||
|
const stored = getStoredSession(storage);
|
||||||
|
expect(stored.hostSecret).toBe('secret-abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update playerId on reconnection', () => {
|
||||||
|
const storage = new Map<string, string>();
|
||||||
|
const oldSession = {
|
||||||
|
pin: '123456',
|
||||||
|
role: 'CLIENT' as const,
|
||||||
|
playerName: 'TestPlayer',
|
||||||
|
playerId: 'old-peer-id'
|
||||||
|
};
|
||||||
|
|
||||||
|
storeSession(storage, oldSession);
|
||||||
|
|
||||||
|
// Simulate reconnection - update playerId while preserving other fields
|
||||||
|
const newSession = { ...oldSession, playerId: 'new-peer-id' };
|
||||||
|
storeSession(storage, newSession);
|
||||||
|
|
||||||
|
const stored = getStoredSession(storage);
|
||||||
|
expect(stored.playerId).toBe('new-peer-id');
|
||||||
|
expect(stored.playerName).toBe('TestPlayer'); // Name should be preserved
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear session on goHomeFromDisconnected', () => {
|
||||||
|
const storage = new Map<string, string>();
|
||||||
|
const session = {
|
||||||
|
pin: '123456',
|
||||||
|
role: 'CLIENT' as const,
|
||||||
|
playerName: 'TestPlayer',
|
||||||
|
};
|
||||||
|
|
||||||
|
storeSession(storage, session);
|
||||||
|
expect(getStoredSession(storage)).not.toBeNull();
|
||||||
|
|
||||||
|
// Simulate goHomeFromDisconnected
|
||||||
|
clearStoredSession(storage);
|
||||||
|
|
||||||
|
expect(getStoredSession(storage)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
29
types.ts
29
types.ts
|
|
@ -8,7 +8,10 @@ export type GameState =
|
||||||
| 'QUESTION'
|
| 'QUESTION'
|
||||||
| 'REVEAL'
|
| 'REVEAL'
|
||||||
| 'SCOREBOARD'
|
| 'SCOREBOARD'
|
||||||
| 'PODIUM';
|
| 'PODIUM'
|
||||||
|
| 'DISCONNECTED'
|
||||||
|
| 'WAITING_TO_REJOIN'
|
||||||
|
| 'HOST_RECONNECTED';
|
||||||
|
|
||||||
export type GameRole = 'HOST' | 'CLIENT';
|
export type GameRole = 'HOST' | 'CLIENT';
|
||||||
|
|
||||||
|
|
@ -112,6 +115,7 @@ export interface Player {
|
||||||
previousScore: number;
|
previousScore: number;
|
||||||
streak: number;
|
streak: number;
|
||||||
lastAnswerCorrect: boolean | null;
|
lastAnswerCorrect: boolean | null;
|
||||||
|
selectedShape: 'triangle' | 'diamond' | 'circle' | 'square' | null;
|
||||||
pointsBreakdown: PointsBreakdown | null;
|
pointsBreakdown: PointsBreakdown | null;
|
||||||
isBot: boolean;
|
isBot: boolean;
|
||||||
avatarSeed: number;
|
avatarSeed: number;
|
||||||
|
|
@ -120,8 +124,25 @@ export interface Player {
|
||||||
|
|
||||||
// Network Types
|
// Network Types
|
||||||
export type NetworkMessage =
|
export type NetworkMessage =
|
||||||
| { type: 'JOIN'; payload: { name: string } }
|
| { type: 'JOIN'; payload: { name: string; reconnect?: boolean; previousId?: string } }
|
||||||
| { type: 'WELCOME'; payload: { playerId: string; quizTitle: string; players: Player[] } }
|
| { type: 'WELCOME'; payload: {
|
||||||
|
playerId: string;
|
||||||
|
quizTitle: string;
|
||||||
|
players: Player[];
|
||||||
|
gameState?: GameState;
|
||||||
|
score?: number;
|
||||||
|
streak?: number;
|
||||||
|
hasAnswered?: boolean;
|
||||||
|
lastAnswerCorrect?: boolean | null;
|
||||||
|
lastPointsEarned?: number;
|
||||||
|
selectedShape?: 'triangle' | 'diamond' | 'circle' | 'square' | null;
|
||||||
|
currentQuestionIndex?: number;
|
||||||
|
totalQuestions?: number;
|
||||||
|
questionText?: string;
|
||||||
|
options?: AnswerOption[];
|
||||||
|
correctShape?: string;
|
||||||
|
timeLeft?: number;
|
||||||
|
} }
|
||||||
| { type: 'PLAYER_JOINED'; payload: { player: Player } }
|
| { type: 'PLAYER_JOINED'; payload: { player: Player } }
|
||||||
| { type: 'GAME_START'; payload: {} }
|
| { type: 'GAME_START'; payload: {} }
|
||||||
| { type: 'START_COUNTDOWN'; payload: { duration: number } }
|
| { type: 'START_COUNTDOWN'; payload: { duration: number } }
|
||||||
|
|
@ -136,7 +157,7 @@ export type NetworkMessage =
|
||||||
options: AnswerOption[];
|
options: AnswerOption[];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| { type: 'ANSWER'; payload: { playerId: string; isCorrect: boolean } }
|
| { type: 'ANSWER'; payload: { playerId: string; isCorrect: boolean; selectedShape: 'triangle' | 'diamond' | 'circle' | 'square' } }
|
||||||
| { type: 'RESULT'; payload: { isCorrect: boolean; scoreAdded: number; newScore: number; breakdown: PointsBreakdown } }
|
| { type: 'RESULT'; payload: { isCorrect: boolean; scoreAdded: number; newScore: number; breakdown: PointsBreakdown } }
|
||||||
| { type: 'TIME_SYNC'; payload: { timeLeft: number } }
|
| { type: 'TIME_SYNC'; payload: { timeLeft: number } }
|
||||||
| { type: 'TIME_UP'; payload: {} }
|
| { type: 'TIME_UP'; payload: {} }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue