feat: add comprehensive game configuration system
Add a centralized game configuration system that allows customizable scoring mechanics and game rules. Users can now set default game configurations that persist across sessions, and individual quizzes can have their own configuration overrides. ## New Features ### Game Configuration Options - Shuffle Questions: Randomize question order when starting a game - Shuffle Answers: Randomize answer positions for each question - Host Participates: Toggle whether the host plays as a competitor or spectates (host now shows as 'Spectator' when not participating) - Streak Bonus: Multiplied points for consecutive correct answers, with configurable threshold and multiplier values - Comeback Bonus: Extra points for players ranked below top 3 - Wrong Answer Penalty: Deduct percentage of max points for incorrect answers (configurable percentage) - First Correct Bonus: Extra points for the first player to answer correctly on each question ### Default Settings Management - New Settings icon in landing page header (authenticated users only) - DefaultConfigModal for editing user-wide default game settings - Default configs are loaded when creating new quizzes - Defaults persist to database via new user API endpoints ### Reusable UI Components - GameConfigPanel: Comprehensive toggle-based settings panel with expandable sub-options, tooltips, and suggested values based on question count - DefaultConfigModal: Modal wrapper for editing default configurations ## Technical Changes ### Frontend - New useUserConfig hook for fetching/saving user default configurations - QuizEditor now uses GameConfigPanel instead of inline toggle checkboxes - GameScreen handles spectator mode with disabled answer buttons - Updated useGame hook with new scoring calculations and config state - Improved useAuthenticatedFetch with deduped silent refresh and redirect-once pattern to prevent multiple auth redirects ### Backend - Added game_config column to quizzes table (JSON storage) - Added default_game_config column to users table - New PATCH endpoint for quiz config updates: /api/quizzes/:id/config - New PUT endpoint for user defaults: /api/users/me/default-config - Auto-migration in connection.ts for existing databases ### Scoring System - New calculatePoints() function in constants.ts handles all scoring logic including streaks, comebacks, penalties, and first-correct bonus - New calculateBasePoints() for time-based point calculation - New getPlayerRank() helper for comeback bonus eligibility ### Tests - Added tests for DefaultConfigModal component - Added tests for GameConfigPanel component - Added tests for QuizEditor config integration - Added tests for useUserConfig hook - Updated API tests for new endpoints ## Type Changes - Added GameConfig interface with all configuration options - Added DEFAULT_GAME_CONFIG constant with sensible defaults - Quiz type now includes optional config property
This commit is contained in:
parent
90fba17a1e
commit
af21f2bcdc
23 changed files with 2925 additions and 133 deletions
|
|
@ -1,10 +1,13 @@
|
|||
import { useAuth } from 'react-oidc-context';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
let isRedirecting = false;
|
||||
|
||||
export const useAuthenticatedFetch = () => {
|
||||
const auth = useAuth();
|
||||
const silentRefreshInProgress = useRef<Promise<string> | null>(null);
|
||||
|
||||
const isTokenExpired = useCallback(() => {
|
||||
if (!auth.user?.expires_at) return true;
|
||||
|
|
@ -14,28 +17,58 @@ export const useAuthenticatedFetch = () => {
|
|||
return now >= expiresAt - bufferMs;
|
||||
}, [auth.user?.expires_at]);
|
||||
|
||||
const redirectToLogin = useCallback(() => {
|
||||
if (isRedirecting) return;
|
||||
isRedirecting = true;
|
||||
auth.signinRedirect();
|
||||
}, [auth]);
|
||||
|
||||
const attemptSilentRefresh = useCallback(async (): Promise<string | null> => {
|
||||
if (silentRefreshInProgress.current) {
|
||||
return silentRefreshInProgress.current;
|
||||
}
|
||||
|
||||
silentRefreshInProgress.current = (async () => {
|
||||
try {
|
||||
const user = await auth.signinSilent();
|
||||
return user?.access_token || null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
silentRefreshInProgress.current = null;
|
||||
}
|
||||
})() as Promise<string>;
|
||||
|
||||
return silentRefreshInProgress.current;
|
||||
}, [auth]);
|
||||
|
||||
const ensureValidToken = useCallback(async (): Promise<string> => {
|
||||
if (isRedirecting) {
|
||||
throw new Error('Session expired, redirecting to login');
|
||||
}
|
||||
|
||||
if (!auth.user?.access_token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
if (isTokenExpired()) {
|
||||
try {
|
||||
const user = await auth.signinSilent();
|
||||
if (user?.access_token) {
|
||||
return user.access_token;
|
||||
}
|
||||
} catch {
|
||||
auth.signinRedirect();
|
||||
throw new Error('Session expired, redirecting to login');
|
||||
const newToken = await attemptSilentRefresh();
|
||||
if (newToken) {
|
||||
return newToken;
|
||||
}
|
||||
redirectToLogin();
|
||||
throw new Error('Session expired, redirecting to login');
|
||||
}
|
||||
|
||||
return auth.user.access_token;
|
||||
}, [auth, isTokenExpired]);
|
||||
}, [auth.user?.access_token, isTokenExpired, attemptSilentRefresh, redirectToLogin]);
|
||||
|
||||
const authFetch = useCallback(
|
||||
async (path: string, options: RequestInit = {}): Promise<Response> => {
|
||||
if (isRedirecting) {
|
||||
throw new Error('Session expired, redirecting to login');
|
||||
}
|
||||
|
||||
if (!navigator.onLine) {
|
||||
throw new Error('You appear to be offline. Please check your connection.');
|
||||
}
|
||||
|
|
@ -61,21 +94,18 @@ export const useAuthenticatedFetch = () => {
|
|||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
try {
|
||||
const user = await auth.signinSilent();
|
||||
if (user?.access_token) {
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
Authorization: `Bearer ${user.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
auth.signinRedirect();
|
||||
const newToken = await attemptSilentRefresh();
|
||||
if (newToken) {
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
Authorization: `Bearer ${newToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
redirectToLogin();
|
||||
throw new Error('Session expired, redirecting to login');
|
||||
}
|
||||
|
||||
|
|
@ -85,7 +115,7 @@ export const useAuthenticatedFetch = () => {
|
|||
|
||||
return response;
|
||||
},
|
||||
[auth, ensureValidToken]
|
||||
[ensureValidToken, attemptSilentRefresh, redirectToLogin]
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
|||
100
hooks/useGame.ts
100
hooks/useGame.ts
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument } from '../types';
|
||||
import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question, GenerateQuizOptions, ProcessedDocument, GameConfig, DEFAULT_GAME_CONFIG } from '../types';
|
||||
import { generateQuiz } from '../services/geminiService';
|
||||
import { POINTS_PER_QUESTION, QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS } from '../constants';
|
||||
import { QUESTION_TIME, QUESTION_TIME_MS, PLAYER_COLORS, calculatePoints, getPlayerRank } from '../constants';
|
||||
import { Peer, DataConnection } from 'peerjs';
|
||||
|
||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:3001';
|
||||
|
|
@ -25,6 +25,8 @@ export const useGame = () => {
|
|||
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 timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const peerRef = useRef<Peer | null>(null);
|
||||
|
|
@ -36,11 +38,13 @@ export const useGame = () => {
|
|||
const playersRef = useRef<Player[]>([]);
|
||||
const currentQuestionIndexRef = useRef(0);
|
||||
const quizRef = useRef<Quiz | null>(null);
|
||||
const gameConfigRef = useRef<GameConfig>(DEFAULT_GAME_CONFIG);
|
||||
|
||||
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]);
|
||||
|
||||
const generateGamePin = () => Math.floor(Math.random() * 900000) + 100000 + "";
|
||||
|
||||
|
|
@ -123,9 +127,10 @@ export const useGame = () => {
|
|||
setPendingQuizToSave(prev => prev ? { ...prev, quiz: updatedQuiz } : { quiz: updatedQuiz, topic: '' });
|
||||
};
|
||||
|
||||
const startGameFromEditor = (finalQuiz: Quiz) => {
|
||||
const startGameFromEditor = (finalQuiz: Quiz, config: GameConfig) => {
|
||||
setQuiz(finalQuiz);
|
||||
initializeHostGame(finalQuiz);
|
||||
setGameConfig(config);
|
||||
initializeHostGame(finalQuiz, config.hostParticipates);
|
||||
};
|
||||
|
||||
const backFromEditor = () => {
|
||||
|
|
@ -139,7 +144,7 @@ export const useGame = () => {
|
|||
// This prevents stale closures in the PeerJS event listeners
|
||||
const handleHostDataRef = useRef<(conn: DataConnection, data: NetworkMessage) => void>(() => {});
|
||||
|
||||
const initializeHostGame = (newQuiz: Quiz) => {
|
||||
const initializeHostGame = (newQuiz: Quiz, hostParticipates: boolean = true) => {
|
||||
setQuiz(newQuiz);
|
||||
const pin = generateGamePin();
|
||||
setGamePin(pin);
|
||||
|
|
@ -148,19 +153,25 @@ export const useGame = () => {
|
|||
peerRef.current = peer;
|
||||
|
||||
peer.on('open', (id) => {
|
||||
const hostPlayer: Player = {
|
||||
id: 'host',
|
||||
name: 'Host',
|
||||
score: 0,
|
||||
streak: 0,
|
||||
lastAnswerCorrect: null,
|
||||
isBot: false,
|
||||
avatarSeed: Math.random(),
|
||||
color: PLAYER_COLORS[0]
|
||||
};
|
||||
setPlayers([hostPlayer]);
|
||||
setCurrentPlayerId('host');
|
||||
setCurrentPlayerName('Host');
|
||||
if (hostParticipates) {
|
||||
const hostPlayer: Player = {
|
||||
id: 'host',
|
||||
name: 'Host',
|
||||
score: 0,
|
||||
streak: 0,
|
||||
lastAnswerCorrect: null,
|
||||
isBot: false,
|
||||
avatarSeed: Math.random(),
|
||||
color: PLAYER_COLORS[0]
|
||||
};
|
||||
setPlayers([hostPlayer]);
|
||||
setCurrentPlayerId('host');
|
||||
setCurrentPlayerName('Host');
|
||||
} else {
|
||||
setPlayers([]);
|
||||
setCurrentPlayerId(null);
|
||||
setCurrentPlayerName(null);
|
||||
}
|
||||
setGameState('LOBBY');
|
||||
});
|
||||
|
||||
|
|
@ -205,12 +216,28 @@ export const useGame = () => {
|
|||
|
||||
if (!currentPlayer || currentPlayer.lastAnswerCorrect !== null) return;
|
||||
|
||||
const points = isCorrect ? Math.round(POINTS_PER_QUESTION * (timeLeftRef.current / QUESTION_TIME_MS)) : 0;
|
||||
const newScore = currentPlayer.score + points;
|
||||
const isFirstCorrect = isCorrect && firstCorrectPlayerId === null;
|
||||
if (isFirstCorrect) {
|
||||
setFirstCorrectPlayerId(playerId);
|
||||
}
|
||||
|
||||
const newStreak = isCorrect ? currentPlayer.streak + 1 : 0;
|
||||
const playerRank = getPlayerRank(playerId, playersRef.current);
|
||||
|
||||
const points = calculatePoints({
|
||||
isCorrect,
|
||||
timeLeftMs: timeLeftRef.current,
|
||||
questionTimeMs: QUESTION_TIME_MS,
|
||||
streak: newStreak,
|
||||
playerRank,
|
||||
isFirstCorrect,
|
||||
config: gameConfigRef.current,
|
||||
});
|
||||
const newScore = Math.max(0, currentPlayer.score + points);
|
||||
|
||||
setPlayers(prev => prev.map(p => {
|
||||
if (p.id !== playerId) return p;
|
||||
return { ...p, score: newScore, streak: isCorrect ? p.streak + 1 : 0, lastAnswerCorrect: isCorrect };
|
||||
return { ...p, score: newScore, streak: newStreak, lastAnswerCorrect: isCorrect };
|
||||
}));
|
||||
|
||||
conn.send({ type: 'RESULT', payload: { isCorrect, scoreAdded: points, newScore } });
|
||||
|
|
@ -256,6 +283,7 @@ export const useGame = () => {
|
|||
setLastPointsEarned(null);
|
||||
setSelectedOption(null);
|
||||
setTimeLeft(QUESTION_TIME_MS);
|
||||
setFirstCorrectPlayerId(null);
|
||||
setPlayers(prev => prev.map(p => ({ ...p, lastAnswerCorrect: null })));
|
||||
|
||||
const currentQuiz = quizRef.current;
|
||||
|
|
@ -421,18 +449,38 @@ export const useGame = () => {
|
|||
|
||||
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 points = isCorrect ? Math.round(POINTS_PER_QUESTION * (timeLeftRef.current / QUESTION_TIME_MS)) : 0;
|
||||
setLastPointsEarned(points);
|
||||
|
||||
const hostPlayer = playersRef.current.find(p => p.id === 'host');
|
||||
const newScore = (hostPlayer?.score || 0) + points;
|
||||
const newStreak = isCorrect ? (hostPlayer?.streak || 0) + 1 : 0;
|
||||
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 points = calculatePoints({
|
||||
isCorrect,
|
||||
timeLeftMs: timeLeftRef.current,
|
||||
questionTimeMs: QUESTION_TIME_MS,
|
||||
streak: newStreak,
|
||||
playerRank,
|
||||
isFirstCorrect,
|
||||
config: gameConfigRef.current,
|
||||
});
|
||||
|
||||
setLastPointsEarned(points);
|
||||
const newScore = Math.max(0, (hostPlayer?.score || 0) + points);
|
||||
setCurrentPlayerScore(newScore);
|
||||
setCurrentStreak(newStreak);
|
||||
|
||||
|
|
@ -462,7 +510,7 @@ export const useGame = () => {
|
|||
}, []);
|
||||
|
||||
return {
|
||||
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId,
|
||||
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape, selectedOption, currentPlayerScore, currentStreak, currentPlayerId, gameConfig,
|
||||
pendingQuizToSave, dismissSavePrompt, sourceQuizId,
|
||||
startQuizGen, startManualCreation, finalizeManualQuiz, loadSavedQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion, showScoreboard,
|
||||
updateQuizFromEditor, startGameFromEditor, backFromEditor
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useCallback, useRef } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAuthenticatedFetch } from './useAuthenticatedFetch';
|
||||
import type { Quiz, QuizSource, SavedQuiz, QuizListItem } from '../types';
|
||||
import type { Quiz, QuizSource, SavedQuiz, QuizListItem, GameConfig } from '../types';
|
||||
|
||||
interface UseQuizLibraryReturn {
|
||||
quizzes: QuizListItem[];
|
||||
|
|
@ -14,6 +14,7 @@ interface UseQuizLibraryReturn {
|
|||
loadQuiz: (id: string) => Promise<SavedQuiz>;
|
||||
saveQuiz: (quiz: Quiz, source: QuizSource, aiTopic?: string) => Promise<string>;
|
||||
updateQuiz: (id: string, quiz: Quiz) => Promise<void>;
|
||||
updateQuizConfig: (id: string, config: GameConfig) => Promise<void>;
|
||||
deleteQuiz: (id: string) => Promise<void>;
|
||||
retry: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
|
|
@ -126,6 +127,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
|
|||
title: quiz.title,
|
||||
source,
|
||||
aiTopic,
|
||||
gameConfig: quiz.config,
|
||||
questions: quiz.questions.map(q => ({
|
||||
text: q.text,
|
||||
timeLimit: q.timeLimit,
|
||||
|
|
@ -170,6 +172,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
|
|||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
title: quiz.title,
|
||||
gameConfig: quiz.config,
|
||||
questions: quiz.questions.map(q => ({
|
||||
text: q.text,
|
||||
timeLimit: q.timeLimit,
|
||||
|
|
@ -203,6 +206,25 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
|
|||
}
|
||||
}, [authFetch]);
|
||||
|
||||
const updateQuizConfig = useCallback(async (id: string, config: GameConfig): Promise<void> => {
|
||||
try {
|
||||
const response = await authFetch(`/api/quizzes/${id}/config`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ gameConfig: config }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update config');
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update config';
|
||||
if (!message.includes('redirecting')) {
|
||||
toast.error(message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}, [authFetch]);
|
||||
|
||||
const deleteQuiz = useCallback(async (id: string): Promise<void> => {
|
||||
setDeletingQuizId(id);
|
||||
setError(null);
|
||||
|
|
@ -253,6 +275,7 @@ export const useQuizLibrary = (): UseQuizLibraryReturn => {
|
|||
loadQuiz,
|
||||
saveQuiz,
|
||||
updateQuiz,
|
||||
updateQuizConfig,
|
||||
deleteQuiz,
|
||||
retry,
|
||||
clearError,
|
||||
|
|
|
|||
73
hooks/useUserConfig.ts
Normal file
73
hooks/useUserConfig.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { useState, useCallback, useEffect } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useAuthenticatedFetch } from './useAuthenticatedFetch';
|
||||
import type { GameConfig } from '../types';
|
||||
import { DEFAULT_GAME_CONFIG } from '../types';
|
||||
|
||||
interface UseUserConfigReturn {
|
||||
defaultConfig: GameConfig;
|
||||
loading: boolean;
|
||||
saving: boolean;
|
||||
fetchDefaultConfig: () => Promise<void>;
|
||||
saveDefaultConfig: (config: GameConfig) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useUserConfig = (): UseUserConfigReturn => {
|
||||
const { authFetch, isAuthenticated } = useAuthenticatedFetch();
|
||||
const [defaultConfig, setDefaultConfig] = useState<GameConfig>(DEFAULT_GAME_CONFIG);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchDefaultConfig = useCallback(async () => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await authFetch('/api/users/me');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.defaultGameConfig) {
|
||||
setDefaultConfig(data.defaultGameConfig);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [authFetch, isAuthenticated]);
|
||||
|
||||
const saveDefaultConfig = useCallback(async (config: GameConfig) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await authFetch('/api/users/me/default-config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ defaultGameConfig: config }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save defaults');
|
||||
}
|
||||
|
||||
setDefaultConfig(config);
|
||||
toast.success('Default settings saved!');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to save defaults';
|
||||
toast.error(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [authFetch]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDefaultConfig();
|
||||
}, [fetchDefaultConfig]);
|
||||
|
||||
return {
|
||||
defaultConfig,
|
||||
loading,
|
||||
saving,
|
||||
fetchDefaultConfig,
|
||||
saveDefaultConfig,
|
||||
};
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue