1425 lines
46 KiB
TypeScript
1425 lines
46 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
import { useNavigate, useLocation } from 'react-router-dom';
|
|
import { useAuth } from 'react-oidc-context';
|
|
import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument, GameConfig, DEFAULT_GAME_CONFIG, PointsBreakdown } from '../types';
|
|
import { generateQuiz } from '../services/geminiService';
|
|
import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePointsWithBreakdown, getPlayerRank } from '../constants';
|
|
import { Peer, DataConnection } from 'peerjs';
|
|
import { uniqueNamesGenerator, adjectives, animals } from 'unique-names-generator';
|
|
|
|
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
|
|
const SESSION_STORAGE_KEY = 'kaboot_session';
|
|
const DRAFT_QUIZ_KEY = 'kaboot_draft_quiz';
|
|
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);
|
|
};
|
|
|
|
interface DraftQuiz {
|
|
quiz: Quiz;
|
|
topic?: string;
|
|
sourceQuizId?: string;
|
|
}
|
|
|
|
const getDraftQuiz = (): DraftQuiz | null => {
|
|
try {
|
|
const stored = sessionStorage.getItem(DRAFT_QUIZ_KEY);
|
|
return stored ? JSON.parse(stored) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const storeDraftQuiz = (draft: DraftQuiz) => {
|
|
sessionStorage.setItem(DRAFT_QUIZ_KEY, JSON.stringify(draft));
|
|
};
|
|
|
|
const clearDraftQuiz = () => {
|
|
sessionStorage.removeItem(DRAFT_QUIZ_KEY);
|
|
};
|
|
|
|
export const useGame = () => {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const auth = useAuth();
|
|
|
|
const [role, setRole] = useState<GameRole>('HOST');
|
|
const [gameState, setGameState] = useState<GameState>('LANDING');
|
|
const [quiz, setQuiz] = useState<Quiz | null>(null);
|
|
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
|
const [players, setPlayers] = useState<Player[]>([]);
|
|
const [timeLeft, setTimeLeft] = useState(0);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [hasAnswered, setHasAnswered] = useState(false);
|
|
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);
|
|
const [currentPlayerId, setCurrentPlayerId] = useState<string | null>(null);
|
|
const [currentPlayerName, setCurrentPlayerName] = useState<string | null>(null);
|
|
const [pendingQuizToSave, setPendingQuizToSave] = useState<{ quiz: Quiz; topic: string } | null>(null);
|
|
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);
|
|
|
|
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");
|
|
const firstCorrectPlayerIdRef = useRef<string | null>(null);
|
|
const currentCorrectShapeRef = useRef<string | null>(null);
|
|
|
|
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]);
|
|
useEffect(() => { firstCorrectPlayerIdRef.current = firstCorrectPlayerId; }, [firstCorrectPlayerId]);
|
|
useEffect(() => { currentCorrectShapeRef.current = currentCorrectShape; }, [currentCorrectShape]);
|
|
|
|
const isInitializingFromUrl = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (isInitializingFromUrl.current) return;
|
|
if (auth.isLoading) return;
|
|
|
|
const getTargetPath = () => {
|
|
if (location.pathname === '/callback') {
|
|
return '/';
|
|
}
|
|
|
|
switch (gameState) {
|
|
case 'LANDING':
|
|
if (gamePin && location.pathname.startsWith('/play/')) {
|
|
return `/play/${gamePin}`;
|
|
}
|
|
return '/';
|
|
case 'CREATING':
|
|
case 'GENERATING':
|
|
return '/create';
|
|
case 'EDITING':
|
|
return sourceQuizId ? `/edit/${sourceQuizId}` : '/edit/draft';
|
|
case 'LOBBY':
|
|
case 'COUNTDOWN':
|
|
case 'QUESTION':
|
|
case 'REVEAL':
|
|
case 'SCOREBOARD':
|
|
case 'PODIUM':
|
|
case 'HOST_RECONNECTED':
|
|
if (gamePin) {
|
|
return role === 'HOST' ? `/host/${gamePin}` : `/play/${gamePin}`;
|
|
}
|
|
return '/';
|
|
case 'DISCONNECTED':
|
|
case 'WAITING_TO_REJOIN':
|
|
if (gamePin) {
|
|
return `/play/${gamePin}`;
|
|
}
|
|
return '/';
|
|
default:
|
|
return '/';
|
|
}
|
|
};
|
|
|
|
const targetPath = getTargetPath();
|
|
if (location.pathname !== targetPath) {
|
|
const useReplace = ['COUNTDOWN', 'QUESTION', 'REVEAL', 'SCOREBOARD'].includes(gameState);
|
|
navigate(targetPath + location.search, { replace: useReplace });
|
|
}
|
|
}, [gameState, gamePin, role, navigate, location.pathname, location.search, sourceQuizId, auth.isLoading]);
|
|
|
|
useEffect(() => {
|
|
if (auth.isLoading) return;
|
|
|
|
const isLibraryQuizRoute = /^\/edit\/[a-zA-Z0-9-]+$/.test(location.pathname) &&
|
|
location.pathname !== '/edit/draft';
|
|
|
|
if (isLibraryQuizRoute && !auth.isAuthenticated) {
|
|
clearDraftQuiz();
|
|
auth.signinRedirect();
|
|
}
|
|
}, [auth.isLoading, auth.isAuthenticated, location.pathname]);
|
|
|
|
const generateGamePin = () => Math.floor(Math.random() * 900000) + 100000 + "";
|
|
|
|
const generateRandomName = (): string => {
|
|
return uniqueNamesGenerator({
|
|
dictionaries: [adjectives, animals],
|
|
separator: ' ',
|
|
style: 'capital',
|
|
length: 2,
|
|
});
|
|
};
|
|
|
|
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,
|
|
firstCorrectPlayerId: firstCorrectPlayerIdRef.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);
|
|
setRole('HOST');
|
|
setGamePin(session.pin);
|
|
setGameState('DISCONNECTED'); // Show loading state on disconnected screen
|
|
setCurrentPlayerName("Host");
|
|
|
|
const hostData = await fetchHostSession(session.pin, session.hostSecret);
|
|
|
|
if (!hostData) {
|
|
clearStoredSession();
|
|
setIsReconnecting(false);
|
|
setGameState('LANDING');
|
|
return;
|
|
}
|
|
|
|
setHostSecret(session.hostSecret);
|
|
setQuiz(hostData.quiz);
|
|
setGameConfig(hostData.gameConfig);
|
|
setCurrentQuestionIndex(hostData.currentQuestionIndex || 0);
|
|
setFirstCorrectPlayerId(hostData.firstCorrectPlayerId || null);
|
|
|
|
const hostPlayer = (hostData.players || []).find((p: Player) => p.id === 'host');
|
|
if (hostPlayer) {
|
|
setCurrentPlayerId('host');
|
|
setCurrentPlayerName('Host');
|
|
setCurrentPlayerScore(hostPlayer.score);
|
|
setCurrentStreak(hostPlayer.streak);
|
|
setPlayers(hostData.players || []);
|
|
|
|
if (hostPlayer.lastAnswerCorrect !== null) {
|
|
setHasAnswered(true);
|
|
if (hostPlayer.pointsBreakdown) {
|
|
setLastPointsEarned(hostPlayer.pointsBreakdown.total);
|
|
}
|
|
}
|
|
} else {
|
|
setPlayers([]);
|
|
setCurrentPlayerName('Host'); // Ensure name is set for DisconnectedScreen
|
|
}
|
|
|
|
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);
|
|
setGameState('WAITING_TO_REJOIN'); // Show "Welcome Back" screen early
|
|
|
|
const gameInfo = await fetchGameInfo(session.pin);
|
|
|
|
if (!gameInfo) {
|
|
setGameState('DISCONNECTED');
|
|
setIsReconnecting(false);
|
|
return;
|
|
}
|
|
|
|
if (gameInfo.gameState === 'PODIUM') {
|
|
clearStoredSession();
|
|
setIsReconnecting(false);
|
|
setGameState('LANDING');
|
|
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 initializeFromUrl = async () => {
|
|
const path = location.pathname;
|
|
|
|
if (path === '/callback') {
|
|
return;
|
|
}
|
|
|
|
const hostMatch = path.match(/^\/host\/(\d+)$/);
|
|
const playMatch = path.match(/^\/play\/(\d+)$/);
|
|
const session = getStoredSession();
|
|
const pinFromUrl = hostMatch ? hostMatch[1] : (playMatch ? playMatch[1] : null);
|
|
|
|
if (pinFromUrl && session && session.pin === pinFromUrl) {
|
|
isInitializingFromUrl.current = true;
|
|
if (session.role === 'HOST') {
|
|
await reconnectAsHost(session);
|
|
} else {
|
|
await reconnectAsClient(session);
|
|
}
|
|
isInitializingFromUrl.current = false;
|
|
return;
|
|
}
|
|
|
|
if (hostMatch) {
|
|
navigate('/', { replace: true });
|
|
return;
|
|
}
|
|
|
|
if (playMatch) {
|
|
const urlPin = playMatch[1];
|
|
if (session && session.pin === urlPin && session.role === 'CLIENT' && session.playerName) {
|
|
isInitializingFromUrl.current = true;
|
|
await reconnectAsClient(session);
|
|
isInitializingFromUrl.current = false;
|
|
return;
|
|
}
|
|
setGamePin(urlPin);
|
|
return;
|
|
}
|
|
|
|
if (path === '/create') {
|
|
isInitializingFromUrl.current = true;
|
|
setGameState('CREATING');
|
|
setRole('HOST');
|
|
isInitializingFromUrl.current = false;
|
|
return;
|
|
}
|
|
|
|
if (path === '/edit/draft') {
|
|
const draft = getDraftQuiz();
|
|
if (draft) {
|
|
isInitializingFromUrl.current = true;
|
|
setRole('HOST');
|
|
setQuiz(draft.quiz);
|
|
setSourceQuizId(null);
|
|
if (draft.topic !== undefined) {
|
|
setPendingQuizToSave({ quiz: draft.quiz, topic: draft.topic });
|
|
}
|
|
setGameState('EDITING');
|
|
isInitializingFromUrl.current = false;
|
|
} else {
|
|
navigate('/', { replace: true });
|
|
}
|
|
return;
|
|
}
|
|
|
|
const editMatch = path.match(/^\/edit\/([a-zA-Z0-9-]+)$/);
|
|
if (editMatch) {
|
|
const quizId = editMatch[1];
|
|
const draft = getDraftQuiz();
|
|
if (draft && draft.sourceQuizId === quizId) {
|
|
isInitializingFromUrl.current = true;
|
|
setRole('HOST');
|
|
setQuiz(draft.quiz);
|
|
setSourceQuizId(quizId);
|
|
setGameState('EDITING');
|
|
isInitializingFromUrl.current = false;
|
|
} else {
|
|
navigate('/', { replace: true });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (session) {
|
|
if (session.role === 'HOST') {
|
|
reconnectAsHost(session);
|
|
} else {
|
|
reconnectAsClient(session);
|
|
}
|
|
}
|
|
};
|
|
|
|
initializeFromUrl();
|
|
|
|
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();
|
|
formData.append('document', file);
|
|
formData.append('useOcr', String(useOcr));
|
|
|
|
const response = await fetch(`${BACKEND_URL}/api/upload`, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
throw new Error(error.error || 'Failed to upload document');
|
|
}
|
|
|
|
return response.json();
|
|
};
|
|
|
|
const startQuizGen = async (options: {
|
|
topic?: string;
|
|
questionCount?: number;
|
|
files?: File[];
|
|
useOcr?: boolean;
|
|
aiProvider?: 'gemini' | 'openrouter' | 'openai' | 'system';
|
|
apiKey?: string;
|
|
geminiModel?: string;
|
|
openRouterModel?: string;
|
|
openAIModel?: string;
|
|
}) => {
|
|
try {
|
|
setGameState('GENERATING');
|
|
setError(null);
|
|
setRole('HOST');
|
|
|
|
let documents: ProcessedDocument[] | undefined;
|
|
if (options.files && options.files.length > 0) {
|
|
documents = await Promise.all(
|
|
options.files.map(file => uploadDocument(file, options.useOcr))
|
|
);
|
|
}
|
|
|
|
const generateOptions: GenerateQuizOptions = {
|
|
topic: options.topic,
|
|
questionCount: options.questionCount,
|
|
documents,
|
|
aiProvider: options.aiProvider,
|
|
apiKey: options.apiKey,
|
|
geminiModel: options.geminiModel,
|
|
openRouterModel: options.openRouterModel,
|
|
openAIModel: options.openAIModel,
|
|
accessToken: options.aiProvider === 'system' ? auth.user?.access_token : undefined,
|
|
};
|
|
|
|
const generatedQuiz = await generateQuiz(generateOptions);
|
|
const saveLabel = options.topic || options.files?.map(f => f.name).join(', ') || '';
|
|
setPendingQuizToSave({ quiz: generatedQuiz, topic: saveLabel });
|
|
setQuiz(generatedQuiz);
|
|
storeDraftQuiz({ quiz: generatedQuiz, topic: saveLabel });
|
|
setGameState('EDITING');
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : "Failed to generate quiz.";
|
|
setError(message);
|
|
setGameState('LANDING');
|
|
}
|
|
};
|
|
|
|
const dismissSavePrompt = () => {
|
|
setPendingQuizToSave(null);
|
|
};
|
|
|
|
const startManualCreation = () => {
|
|
setRole('HOST');
|
|
setGameState('CREATING');
|
|
};
|
|
|
|
const cancelCreation = () => {
|
|
setGameState('LANDING');
|
|
};
|
|
|
|
const finalizeManualQuiz = (manualQuiz: Quiz, saveToLibrary: boolean = false) => {
|
|
if (saveToLibrary) {
|
|
setPendingQuizToSave({ quiz: manualQuiz, topic: '' });
|
|
}
|
|
initializeHostGame(manualQuiz);
|
|
};
|
|
|
|
const loadSavedQuiz = (savedQuiz: Quiz, quizId?: string) => {
|
|
setRole('HOST');
|
|
setQuiz(savedQuiz);
|
|
setSourceQuizId(quizId || null);
|
|
storeDraftQuiz({ quiz: savedQuiz, sourceQuizId: quizId });
|
|
setGameState('EDITING');
|
|
};
|
|
|
|
const updateQuizFromEditor = (updatedQuiz: Quiz) => {
|
|
setQuiz(updatedQuiz);
|
|
setPendingQuizToSave(prev => prev ? { ...prev, quiz: updatedQuiz } : { quiz: updatedQuiz, topic: '' });
|
|
const currentDraft = getDraftQuiz();
|
|
storeDraftQuiz({
|
|
quiz: updatedQuiz,
|
|
topic: currentDraft?.topic,
|
|
sourceQuizId: currentDraft?.sourceQuizId
|
|
});
|
|
};
|
|
|
|
const startGameFromEditor = (finalQuiz: Quiz, config: GameConfig) => {
|
|
setQuiz(finalQuiz);
|
|
setGameConfig(config);
|
|
clearDraftQuiz();
|
|
initializeHostGame(finalQuiz, config.hostParticipates);
|
|
};
|
|
|
|
const backFromEditor = () => {
|
|
setQuiz(null);
|
|
setPendingQuizToSave(null);
|
|
setSourceQuizId(null);
|
|
clearDraftQuiz();
|
|
setGameState('LANDING');
|
|
};
|
|
|
|
const handleHostData = (conn: DataConnection, data: NetworkMessage) => {
|
|
if (data.type === 'JOIN') {
|
|
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 }))
|
|
});
|
|
|
|
let assignedName = payload.name;
|
|
if (!reconnectedPlayer && gameConfigRef.current.randomNamesEnabled) {
|
|
assignedName = generateRandomName();
|
|
}
|
|
|
|
let updatedPlayers = playersRef.current;
|
|
let newPlayer: Player | null = null;
|
|
|
|
if (reconnectedPlayer) {
|
|
updatedPlayers = playersRef.current.map(p => p.id === reconnectedPlayer.id ? { ...p, id: conn.peer } : p);
|
|
setPlayers(updatedPlayers);
|
|
assignedName = reconnectedPlayer.name;
|
|
} else if (!playersRef.current.find(p => p.id === conn.peer)) {
|
|
const colorIndex = playersRef.current.length % PLAYER_COLORS.length;
|
|
newPlayer = {
|
|
id: conn.peer,
|
|
name: assignedName,
|
|
score: 0,
|
|
previousScore: 0,
|
|
streak: 0,
|
|
lastAnswerCorrect: null,
|
|
selectedShape: null,
|
|
pointsBreakdown: null,
|
|
isBot: false,
|
|
avatarSeed: Math.random(),
|
|
color: PLAYER_COLORS[colorIndex]
|
|
};
|
|
updatedPlayers = [...playersRef.current, newPlayer];
|
|
setPlayers(updatedPlayers);
|
|
}
|
|
|
|
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: updatedPlayers,
|
|
gameState: currentState,
|
|
currentQuestionIndex: currentIndex,
|
|
totalQuestions: currentQuiz?.questions.length || 0,
|
|
timeLeft: timeLeftRef.current,
|
|
score: 0,
|
|
streak: 0,
|
|
hasAnswered: false,
|
|
lastAnswerCorrect: null,
|
|
selectedShape: null,
|
|
assignedName,
|
|
};
|
|
|
|
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
|
|
});
|
|
conn.send({ type: 'WELCOME', payload: welcomePayload });
|
|
}
|
|
|
|
if (data.type === 'ANSWER') {
|
|
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) {
|
|
console.log('[HOST] Ignoring answer - player not found or already answered');
|
|
return;
|
|
}
|
|
|
|
const isFirstCorrect = isCorrect && firstCorrectPlayerId === null;
|
|
if (isFirstCorrect) {
|
|
setFirstCorrectPlayerId(playerId);
|
|
}
|
|
|
|
const newStreak = isCorrect ? currentPlayer.streak + 1 : 0;
|
|
const playerRank = getPlayerRank(playerId, playersRef.current);
|
|
|
|
const breakdown = calculatePointsWithBreakdown({
|
|
isCorrect,
|
|
timeLeftMs: timeLeftRef.current,
|
|
questionTimeMs: QUESTION_TIME_MS,
|
|
streak: newStreak,
|
|
playerRank,
|
|
isFirstCorrect,
|
|
config: gameConfigRef.current,
|
|
});
|
|
const newScore = Math.max(0, currentPlayer.score + breakdown.total);
|
|
|
|
const updatedPlayers = playersRef.current.map(p => {
|
|
if (p.id !== playerId) return p;
|
|
return { ...p, score: newScore, previousScore: p.score, streak: newStreak, lastAnswerCorrect: isCorrect, selectedShape, pointsBreakdown: breakdown };
|
|
});
|
|
setPlayers(updatedPlayers);
|
|
|
|
conn.send({ type: 'RESULT', payload: { isCorrect, scoreAdded: breakdown.total, newScore, breakdown } });
|
|
|
|
const allAnswered = updatedPlayers.every(p => p.lastAnswerCorrect !== null);
|
|
if (allAnswered && gameStateRef.current === 'QUESTION') {
|
|
endQuestion();
|
|
}
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
handleHostDataRef.current = handleHostData;
|
|
handleClientDataRef.current = handleClientData;
|
|
});
|
|
|
|
const broadcast = (msg: NetworkMessage) => {
|
|
connectionsRef.current.forEach(conn => { if (conn.open) conn.send(msg); });
|
|
};
|
|
|
|
const startHostGame = () => {
|
|
setCurrentQuestionIndex(0);
|
|
broadcast({ type: 'GAME_START', payload: {} });
|
|
startCountdown();
|
|
};
|
|
|
|
const resumeGame = () => {
|
|
broadcast({ type: 'GAME_START', payload: {} });
|
|
startCountdown(true);
|
|
};
|
|
|
|
const startCountdown = (isResume: boolean = false) => {
|
|
setGameState('COUNTDOWN');
|
|
broadcast({ type: 'START_COUNTDOWN', payload: { duration: 3 } });
|
|
|
|
let count = 3;
|
|
setTimeLeft(count);
|
|
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
timerRef.current = setInterval(() => {
|
|
count--;
|
|
setTimeLeft(count);
|
|
if (count <= 0) {
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
startQuestion(isResume);
|
|
}
|
|
}, 1000);
|
|
};
|
|
|
|
const startQuestion = (isResume: boolean = false) => {
|
|
setGameState('QUESTION');
|
|
setTimeLeft(QUESTION_TIME_MS);
|
|
setFirstCorrectPlayerId(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;
|
|
|
|
if (currentQuiz) {
|
|
const currentQ = currentQuiz.questions[currentIndex];
|
|
const options = currentQ.options || [];
|
|
const correctOpt = options.find(o => o.isCorrect);
|
|
const correctShape = correctOpt?.shape || 'triangle';
|
|
setCurrentCorrectShape(correctShape);
|
|
|
|
const optionsForClient = options.map(o => ({
|
|
...o,
|
|
isCorrect: false
|
|
}));
|
|
|
|
broadcast({
|
|
type: 'QUESTION_START',
|
|
payload: {
|
|
totalQuestions: currentQuiz.questions.length,
|
|
currentQuestionIndex: currentIndex,
|
|
timeLimit: QUESTION_TIME,
|
|
correctShape,
|
|
questionText: currentQ.text,
|
|
options: optionsForClient
|
|
}
|
|
});
|
|
}
|
|
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
let tickCount = 0;
|
|
timerRef.current = setInterval(() => {
|
|
setTimeLeft(prev => {
|
|
if (prev <= 100) { endQuestion(); return 0; }
|
|
const newTime = prev - 100;
|
|
tickCount++;
|
|
if (tickCount % 10 === 0) {
|
|
broadcast({ type: 'TIME_SYNC', payload: { timeLeft: newTime } });
|
|
}
|
|
return newTime;
|
|
});
|
|
}, 100);
|
|
};
|
|
|
|
const endQuestion = () => {
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
|
|
const unansweredPlayers = playersRef.current.filter(p => p.lastAnswerCorrect === null);
|
|
if (unansweredPlayers.length > 0) {
|
|
const updatedPlayers = playersRef.current.map(p => {
|
|
if (p.lastAnswerCorrect !== null) return p;
|
|
|
|
const playerRank = getPlayerRank(p.id, playersRef.current);
|
|
const breakdown = calculatePointsWithBreakdown({
|
|
isCorrect: false,
|
|
timeLeftMs: 0,
|
|
questionTimeMs: QUESTION_TIME_MS,
|
|
streak: 0,
|
|
playerRank,
|
|
isFirstCorrect: false,
|
|
config: gameConfigRef.current,
|
|
});
|
|
const newScore = Math.max(0, p.score + breakdown.total);
|
|
|
|
if (p.id === 'host') {
|
|
setLastPointsEarned(breakdown.total);
|
|
setLastAnswerCorrect(false);
|
|
setCurrentPlayerScore(newScore);
|
|
setCurrentStreak(0);
|
|
} else {
|
|
const conn = connectionsRef.current.get(p.id);
|
|
if (conn?.open) {
|
|
conn.send({ type: 'RESULT', payload: { isCorrect: false, scoreAdded: breakdown.total, newScore, breakdown } });
|
|
}
|
|
}
|
|
|
|
return {
|
|
...p,
|
|
score: newScore,
|
|
previousScore: p.score,
|
|
streak: 0,
|
|
lastAnswerCorrect: false,
|
|
pointsBreakdown: breakdown,
|
|
};
|
|
});
|
|
setPlayers(updatedPlayers);
|
|
}
|
|
|
|
setGameState('REVEAL');
|
|
broadcast({ type: 'TIME_UP', payload: {} });
|
|
};
|
|
|
|
const showScoreboard = () => {
|
|
setGameState('SCOREBOARD');
|
|
broadcast({ type: 'SHOW_SCOREBOARD', payload: { players: playersRef.current } });
|
|
};
|
|
|
|
const nextQuestion = () => {
|
|
const currentQuiz = quizRef.current;
|
|
const currentIndex = currentQuestionIndexRef.current;
|
|
|
|
if (!currentQuiz) return;
|
|
|
|
if (currentIndex < currentQuiz.questions.length - 1) {
|
|
setCurrentQuestionIndex(prev => prev + 1);
|
|
setTimeout(() => startCountdown(), 0);
|
|
} else {
|
|
setGameState('PODIUM');
|
|
broadcast({ type: 'GAME_OVER', payload: { players } });
|
|
|
|
if (gamePinRef.current && hostSecretRef.current) {
|
|
deleteGameSession(gamePinRef.current, hostSecretRef.current);
|
|
}
|
|
clearStoredSession();
|
|
}
|
|
};
|
|
|
|
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 = 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);
|
|
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();
|
|
}
|
|
setGamePin(null);
|
|
setGameState('LANDING');
|
|
setError(null);
|
|
};
|
|
|
|
const handleClientData = (data: NetworkMessage) => {
|
|
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.assignedName) {
|
|
setCurrentPlayerName(payload.assignedName);
|
|
const session = getStoredSession();
|
|
if (session) {
|
|
storeSession({ ...session, playerName: payload.assignedName });
|
|
}
|
|
}
|
|
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');
|
|
setTimeLeft(data.payload.duration);
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
timerRef.current = setInterval(() => {
|
|
setTimeLeft(prev => Math.max(0, prev - 1));
|
|
}, 1000);
|
|
}
|
|
|
|
if (data.type === 'QUESTION_START') {
|
|
setGameState('QUESTION');
|
|
setHasAnswered(false);
|
|
setLastPointsEarned(null);
|
|
setLastAnswerCorrect(null);
|
|
setSelectedOption(null);
|
|
setCurrentQuestionIndex(data.payload.currentQuestionIndex);
|
|
setTimeLeft(data.payload.timeLimit * 1000);
|
|
setCurrentCorrectShape(data.payload.correctShape);
|
|
|
|
setQuiz(prev => {
|
|
const tempQuestions = prev ? [...prev.questions] : [];
|
|
while (tempQuestions.length < data.payload.totalQuestions) {
|
|
tempQuestions.push({ id: `loading-${tempQuestions.length}`, text: '', options: [], timeLimit: 0 });
|
|
}
|
|
tempQuestions[data.payload.currentQuestionIndex] = {
|
|
id: `q-${data.payload.currentQuestionIndex}`,
|
|
text: data.payload.questionText,
|
|
options: data.payload.options,
|
|
timeLimit: data.payload.timeLimit
|
|
};
|
|
return { title: prev?.title || 'Quiz', questions: tempQuestions };
|
|
});
|
|
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
timerRef.current = setInterval(() => setTimeLeft(prev => Math.max(0, prev - 100)), 100);
|
|
}
|
|
|
|
if (data.type === 'RESULT') {
|
|
setLastPointsEarned(data.payload.scoreAdded);
|
|
setLastAnswerCorrect(data.payload.isCorrect);
|
|
setCurrentPlayerScore(data.payload.newScore);
|
|
if (data.payload.isCorrect) {
|
|
setCurrentStreak(prev => prev + 1);
|
|
} else {
|
|
setCurrentStreak(0);
|
|
}
|
|
}
|
|
|
|
if (data.type === 'TIME_SYNC') {
|
|
setTimeLeft(data.payload.timeLeft);
|
|
}
|
|
|
|
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 (gameStateRef.current !== 'WAITING_TO_REJOIN') {
|
|
setGameState('REVEAL');
|
|
}
|
|
}
|
|
|
|
if (data.type === 'SHOW_SCOREBOARD') {
|
|
setGameState('SCOREBOARD');
|
|
setPlayers(data.payload.players);
|
|
}
|
|
if (data.type === 'GAME_OVER') {
|
|
setGameState('PODIUM');
|
|
setPlayers(data.payload.players);
|
|
clearStoredSession();
|
|
}
|
|
};
|
|
|
|
const handleAnswer = (arg: boolean | AnswerOption) => {
|
|
if (hasAnswered || gameState !== 'QUESTION') return;
|
|
if (role === 'HOST' && !gameConfigRef.current.hostParticipates) return;
|
|
|
|
setHasAnswered(true);
|
|
|
|
if (role === 'HOST') {
|
|
const option = arg as AnswerOption;
|
|
const isCorrect = option.isCorrect;
|
|
setSelectedOption(option);
|
|
|
|
const hostPlayer = playersRef.current.find(p => p.id === 'host');
|
|
const currentStrk = hostPlayer?.streak || 0;
|
|
const newStreak = isCorrect ? currentStrk + 1 : 0;
|
|
|
|
const isFirstCorrect = isCorrect && firstCorrectPlayerId === null;
|
|
if (isFirstCorrect) {
|
|
setFirstCorrectPlayerId('host');
|
|
}
|
|
|
|
const playerRank = getPlayerRank('host', playersRef.current);
|
|
|
|
const breakdown = calculatePointsWithBreakdown({
|
|
isCorrect,
|
|
timeLeftMs: timeLeftRef.current,
|
|
questionTimeMs: QUESTION_TIME_MS,
|
|
streak: newStreak,
|
|
playerRank,
|
|
isFirstCorrect,
|
|
config: gameConfigRef.current,
|
|
});
|
|
|
|
setLastPointsEarned(breakdown.total);
|
|
setLastAnswerCorrect(isCorrect);
|
|
const newScore = Math.max(0, (hostPlayer?.score || 0) + breakdown.total);
|
|
setCurrentPlayerScore(newScore);
|
|
setCurrentStreak(newStreak);
|
|
|
|
const updatedPlayers = playersRef.current.map(p => {
|
|
if (p.id !== 'host') return p;
|
|
return { ...p, score: newScore, previousScore: p.score, streak: newStreak, lastAnswerCorrect: isCorrect, selectedShape: option.shape, pointsBreakdown: breakdown };
|
|
});
|
|
setPlayers(updatedPlayers);
|
|
|
|
const allAnswered = updatedPlayers.every(p => p.lastAnswerCorrect !== null);
|
|
if (allAnswered && gameStateRef.current === 'QUESTION') {
|
|
endQuestion();
|
|
}
|
|
} else {
|
|
const option = arg as AnswerOption;
|
|
setSelectedOption(option);
|
|
// Use ref to avoid stale closure - currentCorrectShape state may not be updated yet
|
|
const isCorrect = option.shape === currentCorrectShapeRef.current;
|
|
console.log('[CLIENT] Answering:', { selectedShape: option.shape, currentCorrectShape: currentCorrectShapeRef.current, 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();
|
|
setGamePin(null);
|
|
setQuiz(null);
|
|
setPlayers([]);
|
|
setGameState('LANDING');
|
|
navigate('/', { replace: true });
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (role === 'HOST' && (gameState === 'SCOREBOARD' || gameState === 'PODIUM')) {
|
|
broadcast({ type: gameState === 'SCOREBOARD' ? 'SHOW_SCOREBOARD' : 'GAME_OVER', payload: { players } });
|
|
}
|
|
}, [gameState, players, role]);
|
|
|
|
return {
|
|
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, lastAnswerCorrect, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, gameConfig,
|
|
pendingQuizToSave, dismissSavePrompt, sourceQuizId, isReconnecting, currentPlayerName,
|
|
startQuizGen, startManualCreation, cancelCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard,
|
|
updateQuizFromEditor, startGameFromEditor, backFromEditor, endGame, attemptReconnect, goHomeFromDisconnected, resumeGame
|
|
};
|
|
};
|