Fix stuff

This commit is contained in:
Joey Yakimowich-Payne 2026-01-14 09:07:20 -07:00
commit 32696ad33d
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
13 changed files with 2194 additions and 110 deletions

71
App.tsx
View file

@ -13,6 +13,9 @@ import { RevealScreen } from './components/RevealScreen';
import { SaveQuizPrompt } from './components/SaveQuizPrompt';
import { QuizEditor } from './components/QuizEditor';
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';
const seededRandom = (seed: number) => {
@ -64,6 +67,7 @@ function App() {
handleAnswer,
hasAnswered,
lastPointsEarned,
lastAnswerCorrect,
nextQuestion,
showScoreboard,
currentCorrectShape,
@ -77,7 +81,13 @@ function App() {
updateQuizFromEditor,
startGameFromEditor,
backFromEditor,
gameConfig
gameConfig,
isReconnecting,
currentPlayerName,
attemptReconnect,
goHomeFromDisconnected,
endGame,
resumeGame
} = useGame();
const handleSaveQuiz = async () => {
@ -168,6 +178,7 @@ function App() {
gamePin={gamePin}
role={role}
onStart={startGame}
onEndGame={role === 'HOST' ? endGame : undefined}
/>
{auth.isAuthenticated && pendingQuizToSave && (
<SaveQuizPrompt
@ -180,7 +191,7 @@ function App() {
</>
) : null}
{(gameState === 'COUNTDOWN' || gameState === 'QUESTION') && quiz ? (
{(gameState === 'COUNTDOWN' || gameState === 'QUESTION') ? (
gameState === 'COUNTDOWN' ? (
<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>
@ -188,7 +199,7 @@ function App() {
{timeLeft}
</div>
</div>
) : (
) : quiz?.questions[currentQuestionIndex] ? (
<GameScreen
question={quiz.questions[currentQuestionIndex]}
timeLeft={timeLeft}
@ -201,12 +212,26 @@ function App() {
lastPointsEarned={lastPointsEarned}
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}
{gameState === 'REVEAL' && correctOpt ? (
{gameState === 'REVEAL' ? (
correctOpt ? (
<RevealScreen
isCorrect={lastPointsEarned !== null && lastPointsEarned > 0}
isCorrect={lastAnswerCorrect === true}
pointsEarned={lastPointsEarned || 0}
newScore={currentPlayerScore}
streak={currentStreak}
@ -215,6 +240,12 @@ function App() {
role={role}
onNext={showScoreboard}
/>
) : currentPlayerName ? (
<WaitingToRejoin
playerName={currentPlayerName}
score={currentPlayerScore}
/>
) : null
) : null}
{gameState === 'SCOREBOARD' ? (
@ -232,6 +263,34 @@ function App() {
onRestart={() => window.location.reload()}
/>
) : 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>
<SaveOptionsModal

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

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

View file

@ -1,7 +1,7 @@
import React from 'react';
import { Player } from '../types';
import { motion, AnimatePresence } from 'framer-motion';
import { Sparkles, User } from 'lucide-react';
import { Sparkles, User, X } from 'lucide-react';
import { PlayerAvatar } from './PlayerAvatar';
interface LobbyProps {
@ -10,9 +10,10 @@ interface LobbyProps {
gamePin: string | null;
role: 'HOST' | 'CLIENT';
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 realPlayers = players.filter(p => p.id !== 'host');
@ -71,8 +72,17 @@ export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role,
<motion.div
initial={{ y: 50, opacity: 0 }}
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
onClick={onStart}
disabled={realPlayers.length === 0}

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

View file

@ -5,6 +5,33 @@ import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePointsWithBrea
import { Peer, DataConnection } from 'peerjs';
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 = () => {
const [role, setRole] = useState<GameRole>('HOST');
@ -18,6 +45,7 @@ export const useGame = () => {
const [gamePin, setGamePin] = useState<string | null>(null);
const [currentCorrectShape, setCurrentCorrectShape] = useState<string | null>(null);
const [lastPointsEarned, setLastPointsEarned] = useState<number | null>(null);
const [lastAnswerCorrect, setLastAnswerCorrect] = useState<boolean | null>(null);
const [selectedOption, setSelectedOption] = useState<AnswerOption | null>(null);
const [currentPlayerScore, setCurrentPlayerScore] = useState(0);
const [currentStreak, setCurrentStreak] = useState(0);
@ -27,28 +55,341 @@ export const useGame = () => {
const [sourceQuizId, setSourceQuizId] = useState<string | null>(null);
const [gameConfig, setGameConfig] = useState<GameConfig>(DEFAULT_GAME_CONFIG);
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 syncTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const peerRef = useRef<Peer | null>(null);
const connectionsRef = useRef<Map<string, DataConnection>>(new Map());
const hostConnectionRef = useRef<DataConnection | null>(null);
// Refs for callbacks/async functions to access latest state
const timeLeftRef = useRef(0);
const playersRef = useRef<Player[]>([]);
const currentQuestionIndexRef = useRef(0);
const quizRef = useRef<Quiz | null>(null);
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(() => { playersRef.current = players; }, [players]);
useEffect(() => { currentQuestionIndexRef.current = currentQuestionIndex; }, [currentQuestionIndex]);
useEffect(() => { quizRef.current = quiz; }, [quiz]);
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 + "";
// -- 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 formData = new FormData();
@ -140,85 +481,112 @@ export const useGame = () => {
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) => {
if (data.type === 'JOIN') {
setPlayers(prev => {
if (prev.find(p => p.id === conn.peer)) return prev;
const colorIndex = prev.length % PLAYER_COLORS.length;
const payload = data.payload as { name: string; reconnect?: boolean; previousId?: string };
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 = {
id: conn.peer,
name: data.payload.name,
name: payload.name,
score: 0,
previousScore: 0,
streak: 0,
lastAnswerCorrect: null,
selectedShape: null,
pointsBreakdown: null,
isBot: false,
avatarSeed: Math.random(),
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: { playerId: conn.peer, quizTitle: 'Kaboot', players: [] } });
conn.send({ type: 'WELCOME', payload: welcomePayload });
}
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);
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;
if (isFirstCorrect) {
@ -241,16 +609,16 @@ export const useGame = () => {
setPlayers(prev => prev.map(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 } });
}
};
// Update the ref whenever handleHostData changes (which happens on render)
useEffect(() => {
handleHostDataRef.current = handleHostData;
handleClientDataRef.current = handleClientData;
});
const broadcast = (msg: NetworkMessage) => {
@ -263,7 +631,12 @@ export const useGame = () => {
startCountdown();
};
const startCountdown = () => {
const resumeGame = () => {
broadcast({ type: 'GAME_START', payload: {} });
startCountdown(true);
};
const startCountdown = (isResume: boolean = false) => {
setGameState('COUNTDOWN');
broadcast({ type: 'START_COUNTDOWN', payload: { duration: 3 } });
@ -276,19 +649,33 @@ export const useGame = () => {
setTimeLeft(count);
if (count <= 0) {
if (timerRef.current) clearInterval(timerRef.current);
startQuestion();
startQuestion(isResume);
}
}, 1000);
};
const startQuestion = () => {
const startQuestion = (isResume: boolean = false) => {
setGameState('QUESTION');
setHasAnswered(false);
setLastPointsEarned(null);
setSelectedOption(null);
setTimeLeft(QUESTION_TIME_MS);
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 currentIndex = currentQuestionIndexRef.current;
@ -356,35 +743,224 @@ export const useGame = () => {
} else {
setGameState('PODIUM');
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');
setError(null);
setGamePin(pin);
setCurrentPlayerName(name);
const gameInfo = await fetchGameInfo(pin);
if (!gameInfo) {
setError("Game not found. Check the PIN.");
return;
}
const peer = new Peer();
peerRef.current = peer;
peer.on('open', (id) => {
setCurrentPlayerId(id);
const conn = peer.connect(`kaboot-${pin}`);
hostConnectionRef.current = conn;
conn.on('open', () => {
conn.send({ type: 'JOIN', payload: { name } });
setGameState('LOBBY');
});
conn.on('data', (data: any) => handleClientData(data));
conn.on('close', () => { setError("Disconnected"); setGameState('LANDING'); });
setTimeout(() => { if (!conn.open) setError("Check PIN"); }, 5000);
setCurrentPlayerId(id);
storeSession({ pin, role: 'CLIENT', playerName: name, playerId: id });
connectToHost(peer, gameInfo.hostPeerId, name, false);
});
peer.on('error', () => {
setError("Network error");
});
};
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) => {
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') {
setGameState('COUNTDOWN');
@ -399,6 +975,7 @@ export const useGame = () => {
setGameState('QUESTION');
setHasAnswered(false);
setLastPointsEarned(null);
setLastAnswerCorrect(null);
setSelectedOption(null);
setCurrentQuestionIndex(data.payload.currentQuestionIndex);
setTimeLeft(data.payload.timeLimit * 1000);
@ -424,6 +1001,7 @@ export const useGame = () => {
if (data.type === 'RESULT') {
setLastPointsEarned(data.payload.scoreAdded);
setLastAnswerCorrect(data.payload.isCorrect);
setCurrentPlayerScore(data.payload.newScore);
if (data.payload.isCorrect) {
setCurrentStreak(prev => prev + 1);
@ -437,8 +1015,16 @@ export const useGame = () => {
}
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);
setGameState('REVEAL');
if (gameStateRef.current !== 'WAITING_TO_REJOIN') {
setGameState('REVEAL');
}
}
if (data.type === 'SHOW_SCOREBOARD') {
@ -448,6 +1034,7 @@ export const useGame = () => {
if (data.type === 'GAME_OVER') {
setGameState('PODIUM');
setPlayers(data.payload.players);
clearStoredSession();
}
};
@ -484,39 +1071,45 @@ export const useGame = () => {
});
setLastPointsEarned(breakdown.total);
setLastAnswerCorrect(isCorrect);
const newScore = Math.max(0, (hostPlayer?.score || 0) + breakdown.total);
setCurrentPlayerScore(newScore);
setCurrentStreak(newStreak);
setPlayers(prev => prev.map(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 {
const option = arg as AnswerOption;
setSelectedOption(option);
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(() => {
if (role === 'HOST' && (gameState === 'SCOREBOARD' || gameState === 'PODIUM')) {
broadcast({ type: gameState === 'SCOREBOARD' ? 'SHOW_SCOREBOARD' : 'GAME_OVER', payload: { players } });
}
}, [gameState, players, role]);
useEffect(() => {
return () => {
if (timerRef.current) clearInterval(timerRef.current);
if (peerRef.current) peerRef.current.destroy();
};
}, []);
return {
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, gameConfig,
pendingQuizToSave, dismissSavePrompt, sourceQuizId,
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, lastAnswerCorrect, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, gameConfig,
pendingQuizToSave, dismissSavePrompt, sourceQuizId, isReconnecting, currentPlayerName,
startQuizGen, startManualCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard,
updateQuizFromEditor, startGameFromEditor, backFromEditor
updateQuizFromEditor, startGameFromEditor, backFromEditor, endGame, attemptReconnect, goHomeFromDisconnected, resumeGame
};
};
};

View file

@ -32,6 +32,26 @@ const runMigrations = () => {
db.exec("ALTER TABLE users ADD COLUMN default_game_config TEXT");
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();

View file

@ -41,6 +41,20 @@ CREATE TABLE IF NOT EXISTS answer_options (
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_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_game_sessions_updated ON game_sessions(updated_at);

View file

@ -4,6 +4,7 @@ import { db } from './db/connection.js';
import quizzesRouter from './routes/quizzes.js';
import usersRouter from './routes/users.js';
import uploadRouter from './routes/upload.js';
import gamesRouter from './routes/games.js';
const app = express();
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/users', usersRouter);
app.use('/api/upload', uploadRouter);
app.use('/api/games', gamesRouter);
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
console.error('Unhandled error:', err);

205
server/src/routes/games.ts Normal file
View 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;

View file

@ -1573,6 +1573,427 @@ async function runTests() {
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 ===');
const passed = results.filter((r) => r.passed).length;
const failed = results.filter((r) => !r.passed).length;

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

View file

@ -8,7 +8,10 @@ export type GameState =
| 'QUESTION'
| 'REVEAL'
| 'SCOREBOARD'
| 'PODIUM';
| 'PODIUM'
| 'DISCONNECTED'
| 'WAITING_TO_REJOIN'
| 'HOST_RECONNECTED';
export type GameRole = 'HOST' | 'CLIENT';
@ -112,6 +115,7 @@ export interface Player {
previousScore: number;
streak: number;
lastAnswerCorrect: boolean | null;
selectedShape: 'triangle' | 'diamond' | 'circle' | 'square' | null;
pointsBreakdown: PointsBreakdown | null;
isBot: boolean;
avatarSeed: number;
@ -120,8 +124,25 @@ export interface Player {
// Network Types
export type NetworkMessage =
| { type: 'JOIN'; payload: { name: string } }
| { type: 'WELCOME'; payload: { playerId: string; quizTitle: string; players: Player[] } }
| { type: 'JOIN'; payload: { name: string; reconnect?: boolean; previousId?: string } }
| { 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: 'GAME_START'; payload: {} }
| { type: 'START_COUNTDOWN'; payload: { duration: number } }
@ -136,7 +157,7 @@ export type NetworkMessage =
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: 'TIME_SYNC'; payload: { timeLeft: number } }
| { type: 'TIME_UP'; payload: {} }