From af21f2bcdcf92aabfed733acf0e7fecfed551b3c Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Wed, 14 Jan 2026 01:43:23 -0700 Subject: [PATCH] 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 --- App.tsx | 11 +- components/DefaultConfigModal.tsx | 95 ++++ components/GameConfigPanel.tsx | 325 ++++++++++++ components/GameScreen.tsx | 21 +- components/Landing.tsx | 42 +- components/QuizEditor.tsx | 82 +-- constants.ts | 57 +++ hooks/useAuthenticatedFetch.ts | 80 ++- hooks/useGame.ts | 100 +++- hooks/useQuizLibrary.ts | 25 +- hooks/useUserConfig.ts | 73 +++ server/src/db/connection.ts | 20 + server/src/db/migrations.sql | 8 + server/src/db/schema.sql | 4 +- server/src/routes/quizzes.ts | 62 ++- server/src/routes/users.ts | 39 +- server/tests/api.test.ts | 265 ++++++++++ services/geminiService.ts | 13 +- tests/components/DefaultConfigModal.test.tsx | 330 ++++++++++++ tests/components/GameConfigPanel.test.tsx | 449 +++++++++++++++++ tests/components/QuizEditorConfig.test.tsx | 422 ++++++++++++++++ tests/hooks/useUserConfig.test.tsx | 503 +++++++++++++++++++ types.ts | 32 ++ 23 files changed, 2925 insertions(+), 133 deletions(-) create mode 100644 components/DefaultConfigModal.tsx create mode 100644 components/GameConfigPanel.tsx create mode 100644 hooks/useUserConfig.ts create mode 100644 server/src/db/migrations.sql create mode 100644 tests/components/DefaultConfigModal.test.tsx create mode 100644 tests/components/GameConfigPanel.test.tsx create mode 100644 tests/components/QuizEditorConfig.test.tsx create mode 100644 tests/hooks/useUserConfig.test.tsx diff --git a/App.tsx b/App.tsx index 18cecb2..a0096ac 100644 --- a/App.tsx +++ b/App.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { useAuth } from 'react-oidc-context'; import { useGame } from './hooks/useGame'; import { useQuizLibrary } from './hooks/useQuizLibrary'; +import { useUserConfig } from './hooks/useUserConfig'; import { Landing } from './components/Landing'; import { Lobby } from './components/Lobby'; import { GameScreen } from './components/GameScreen'; @@ -12,7 +13,7 @@ import { RevealScreen } from './components/RevealScreen'; import { SaveQuizPrompt } from './components/SaveQuizPrompt'; import { QuizEditor } from './components/QuizEditor'; import { SaveOptionsModal } from './components/SaveOptionsModal'; -import type { Quiz } from './types'; +import type { Quiz, GameConfig } from './types'; const seededRandom = (seed: number) => { const x = Math.sin(seed * 9999) * 10000; @@ -42,6 +43,7 @@ const FloatingShapes = React.memo(() => { function App() { const auth = useAuth(); const { saveQuiz, updateQuiz, saving } = useQuizLibrary(); + const { defaultConfig } = useUserConfig(); const [showSaveOptions, setShowSaveOptions] = useState(false); const [pendingEditedQuiz, setPendingEditedQuiz] = useState(null); const { @@ -74,7 +76,8 @@ function App() { sourceQuizId, updateQuizFromEditor, startGameFromEditor, - backFromEditor + backFromEditor, + gameConfig } = useGame(); const handleSaveQuiz = async () => { @@ -152,7 +155,8 @@ function App() { onSave={handleEditorSave} onStartGame={startGameFromEditor} onBack={backFromEditor} - sourceQuizId={sourceQuizId} + showSaveButton={auth.isAuthenticated} + defaultConfig={defaultConfig} /> ) : null} @@ -195,6 +199,7 @@ function App() { onAnswer={handleAnswer} hasAnswered={hasAnswered} lastPointsEarned={lastPointsEarned} + hostPlays={gameConfig.hostParticipates} /> ) ) : null} diff --git a/components/DefaultConfigModal.tsx b/components/DefaultConfigModal.tsx new file mode 100644 index 0000000..c2831c1 --- /dev/null +++ b/components/DefaultConfigModal.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { X, Settings, Loader2 } from 'lucide-react'; +import { GameConfigPanel } from './GameConfigPanel'; +import { useBodyScrollLock } from '../hooks/useBodyScrollLock'; +import type { GameConfig } from '../types'; + +interface DefaultConfigModalProps { + isOpen: boolean; + onClose: () => void; + config: GameConfig; + onChange: (config: GameConfig) => void; + onSave: () => void; + saving: boolean; +} + +export const DefaultConfigModal: React.FC = ({ + isOpen, + onClose, + config, + onChange, + onSave, + saving, +}) => { + useBodyScrollLock(isOpen); + + if (!isOpen) return null; + + return ( + + e.stopPropagation()} + > +
+
+
+ +
+
+

Default Game Settings

+

Applied to all new quizzes

+
+
+ +
+ +
+ +
+ +
+ + +
+
+
+ ); +}; diff --git a/components/GameConfigPanel.tsx b/components/GameConfigPanel.tsx new file mode 100644 index 0000000..df29120 --- /dev/null +++ b/components/GameConfigPanel.tsx @@ -0,0 +1,325 @@ +import React, { useState } from 'react'; +import { Shuffle, Eye, Flame, TrendingUp, MinusCircle, Award, Info, ChevronDown, ChevronUp } from 'lucide-react'; +import type { GameConfig } from '../types'; + +interface GameConfigPanelProps { + config: GameConfig; + onChange: (config: GameConfig) => void; + questionCount: number; + compact?: boolean; +} + +interface TooltipProps { + content: string; +} + +const Tooltip: React.FC = ({ content }) => { + const [show, setShow] = useState(false); + const [coords, setCoords] = useState({ top: 0, left: 0 }); + const buttonRef = React.useRef(null); + + const updatePosition = () => { + if (buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect(); + setCoords({ + top: rect.top + rect.height / 2, + left: rect.right + 8, + }); + } + }; + + const handleMouseEnter = () => { + updatePosition(); + setShow(true); + }; + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + updatePosition(); + setShow(!show); + }; + + return ( +
+ + {show && ( +
+ {content} +
+
+ )} +
+ ); +}; + +interface ToggleRowProps { + icon: React.ReactNode; + iconActive: boolean; + label: string; + description: string; + checked: boolean; + onChange: (checked: boolean) => void; + tooltip?: string; + children?: React.ReactNode; +} + +const ToggleRow: React.FC = ({ + icon, + iconActive, + label: labelText, + description, + checked, + onChange, + tooltip, + children, +}) => { + const inputId = React.useId(); + + return ( +
+
+ +
+ {tooltip && } +
+ ); +}; + +interface NumberInputProps { + label: string; + value: number; + onChange: (value: number) => void; + min?: number; + max?: number; + step?: number; + suffix?: string; +} + +const NumberInput: React.FC = ({ label, value, onChange, min, max, step = 1, suffix }) => ( +
+ {label} +
+ onChange(Number(e.target.value))} + min={min} + max={max} + step={step} + className="w-20 px-3 py-1.5 border-2 border-gray-200 rounded-lg text-center font-bold text-gray-900 bg-white focus:border-theme-primary focus:ring-2 focus:ring-theme-primary/20 outline-none" + /> + {suffix && {suffix}} +
+
+); + +export const GameConfigPanel: React.FC = ({ + config, + onChange, + questionCount, + compact = false, +}) => { + const [expanded, setExpanded] = useState(!compact); + + const update = (partial: Partial) => { + onChange({ ...config, ...partial }); + }; + + const suggestedComebackBonus = Math.round(50 + (questionCount * 5)); + const suggestedFirstCorrectBonus = Math.round(25 + (questionCount * 2.5)); + + if (compact && !expanded) { + return ( + + ); + } + + return ( +
+ {compact && ( + + )} + + } + iconActive={config.shuffleQuestions} + label="Shuffle Questions" + description="Randomize question order when starting" + checked={config.shuffleQuestions} + onChange={(v) => update({ shuffleQuestions: v })} + /> + + } + iconActive={config.shuffleAnswers} + label="Shuffle Answers" + description="Randomize answer positions for each question" + checked={config.shuffleAnswers} + onChange={(v) => update({ shuffleAnswers: v })} + /> + + } + iconActive={config.hostParticipates} + label="Host Participates" + description="Join as a player and answer questions" + checked={config.hostParticipates} + onChange={(v) => update({ hostParticipates: v })} + /> + + } + iconActive={config.streakBonusEnabled} + label="Streak Bonus" + description="Multiply points for consecutive correct answers" + checked={config.streakBonusEnabled} + onChange={(v) => update({ streakBonusEnabled: v })} + tooltip={`After ${config.streakThreshold} correct answers in a row, points are multiplied by ${config.streakMultiplier}x. The multiplier increases by ${((config.streakMultiplier - 1) * 100).toFixed(0)}% for each additional correct answer.`} + > +
+ update({ streakThreshold: v })} + min={2} + max={10} + suffix="correct" + /> + update({ streakMultiplier: v })} + min={1.05} + max={2} + step={0.05} + suffix="x" + /> +
+
+ + } + iconActive={config.comebackBonusEnabled} + label="Comeback Bonus" + description="Extra points for players not in top 3" + checked={config.comebackBonusEnabled} + onChange={(v) => update({ comebackBonusEnabled: v })} + > +
+ update({ comebackBonusPoints: v })} + min={10} + max={500} + suffix="pts" + /> +

+ Suggested for {questionCount} questions: {suggestedComebackBonus} pts +

+
+
+ + } + iconActive={config.penaltyForWrongAnswer} + label="Wrong Answer Penalty" + description="Deduct points for incorrect answers" + checked={config.penaltyForWrongAnswer} + onChange={(v) => update({ penaltyForWrongAnswer: v })} + > +
+ update({ penaltyPercent: v })} + min={5} + max={100} + suffix="%" + /> +

+ Deducts {config.penaltyPercent}% of max points ({Math.round(1000 * config.penaltyPercent / 100)} pts) +

+
+
+ + } + iconActive={config.firstCorrectBonusEnabled} + label="First Correct Bonus" + description="Extra points for first player to answer correctly" + checked={config.firstCorrectBonusEnabled} + onChange={(v) => update({ firstCorrectBonusEnabled: v })} + > +
+ update({ firstCorrectBonusPoints: v })} + min={10} + max={500} + suffix="pts" + /> +

+ Suggested for {questionCount} questions: {suggestedFirstCorrectBonus} pts +

+
+
+
+ ); +}; diff --git a/components/GameScreen.tsx b/components/GameScreen.tsx index c6866fc..060de98 100644 --- a/components/GameScreen.tsx +++ b/components/GameScreen.tsx @@ -13,6 +13,7 @@ interface GameScreenProps { onAnswer: (isCorrect: boolean) => void; hasAnswered: boolean; lastPointsEarned: number | null; + hostPlays?: boolean; } export const GameScreen: React.FC = ({ @@ -24,8 +25,10 @@ export const GameScreen: React.FC = ({ role, onAnswer, hasAnswered, + hostPlays = true, }) => { const isClient = role === 'CLIENT'; + const isSpectator = role === 'HOST' && !hostPlays; const displayOptions = question?.options || []; const timeLeftSeconds = Math.ceil(timeLeft / 1000); @@ -51,7 +54,7 @@ export const GameScreen: React.FC = ({
- {isClient ? 'Controller' : 'Host'} + {isClient ? 'Player' : isSpectator ? 'Spectator' : 'Host'}
@@ -80,12 +83,14 @@ export const GameScreen: React.FC = ({ let opacityClass = "opacity-100"; let scaleClass = "scale-100"; + let cursorClass = ""; - // If answering phase and user answered, dim everything - if (hasAnswered) { + if (isSpectator) { + cursorClass = "cursor-default"; + } else if (hasAnswered) { opacityClass = "opacity-50 cursor-not-allowed grayscale"; scaleClass = "scale-95"; - } + } return ( = ({ initial={{ y: 50, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ delay: idx * 0.03, type: 'spring', stiffness: 500, damping: 30 }} - disabled={hasAnswered} - onClick={() => onAnswer(option as any)} + disabled={hasAnswered || isSpectator} + onClick={() => !isSpectator && onAnswer(option as any)} className={` - ${colorClass} ${opacityClass} ${scaleClass} + ${colorClass} ${opacityClass} ${scaleClass} ${cursorClass} rounded-3xl shadow-[0_8px_0_rgba(0,0,0,0.2)] flex flex-col md:flex-row items-center justify-center md:justify-start p-4 md:p-8 - active:shadow-none active:translate-y-[8px] active:scale-95 + ${!isSpectator ? 'active:shadow-none active:translate-y-[8px] active:scale-95' : ''} transition-all duration-300 relative group overflow-hidden border-b-8 border-black/10 `} > diff --git a/components/Landing.tsx b/components/Landing.tsx index b82c94d..80628cf 100644 --- a/components/Landing.tsx +++ b/components/Landing.tsx @@ -1,11 +1,13 @@ import React, { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, X, FileText, Image, ScanText, Sparkles } from 'lucide-react'; +import { BrainCircuit, Loader2, Play, PenTool, BookOpen, Upload, X, FileText, Image, ScanText, Sparkles, Settings } from 'lucide-react'; import { useAuth } from 'react-oidc-context'; import { AuthButton } from './AuthButton'; import { QuizLibrary } from './QuizLibrary'; +import { DefaultConfigModal } from './DefaultConfigModal'; import { useQuizLibrary } from '../hooks/useQuizLibrary'; -import type { Quiz } from '../types'; +import { useUserConfig } from '../hooks/useUserConfig'; +import type { Quiz, GameConfig } from '../types'; type GenerateMode = 'topic' | 'document'; @@ -31,10 +33,14 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on const [questionCount, setQuestionCount] = useState(10); const [isDragging, setIsDragging] = useState(false); const [useOcr, setUseOcr] = useState(false); + const [defaultConfigOpen, setDefaultConfigOpen] = useState(false); + const [editingDefaultConfig, setEditingDefaultConfig] = useState(null); const hasImageFile = selectedFiles.some(f => f.type.startsWith('image/')); const hasDocumentFile = selectedFiles.some(f => !f.type.startsWith('image/') && !['application/pdf', 'text/plain', 'text/markdown', 'text/csv', 'text/html'].includes(f.type)); const showOcrOption = hasImageFile || hasDocumentFile; + + const { defaultConfig, saving: savingConfig, saveDefaultConfig } = useUserConfig(); const { quizzes, @@ -132,7 +138,19 @@ export const Landing: React.FC = ({ onGenerate, onCreateManual, on return (
-
+
+ {auth.isAuthenticated && ( + + )}
= ({ onGenerate, onCreateManual, on onDeleteQuiz={deleteQuiz} onRetry={retryLibrary} /> + + { + setDefaultConfigOpen(false); + setEditingDefaultConfig(null); + }} + config={editingDefaultConfig || defaultConfig} + onChange={setEditingDefaultConfig} + onSave={async () => { + if (editingDefaultConfig) { + await saveDefaultConfig(editingDefaultConfig); + setDefaultConfigOpen(false); + setEditingDefaultConfig(null); + } + }} + saving={savingConfig} + />
); }; diff --git a/components/QuizEditor.tsx b/components/QuizEditor.tsx index 09b2d28..8cb3157 100644 --- a/components/QuizEditor.tsx +++ b/components/QuizEditor.tsx @@ -1,41 +1,52 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { ArrowLeft, Save, Plus, Play, AlertTriangle, Shuffle } from 'lucide-react'; +import { ArrowLeft, Save, Plus, Play, AlertTriangle } from 'lucide-react'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; -import { Quiz, Question } from '../types'; +import { Quiz, Question, GameConfig, DEFAULT_GAME_CONFIG } from '../types'; import { SortableQuestionCard } from './SortableQuestionCard'; import { QuestionEditModal } from './QuestionEditModal'; +import { GameConfigPanel } from './GameConfigPanel'; import { useBodyScrollLock } from '../hooks/useBodyScrollLock'; import { v4 as uuidv4 } from 'uuid'; interface QuizEditorProps { quiz: Quiz; onSave: (quiz: Quiz) => void; - onStartGame: (quiz: Quiz) => void; + onStartGame: (quiz: Quiz, config: GameConfig) => void; + onConfigChange?: (config: GameConfig) => void; onBack: () => void; showSaveButton?: boolean; isSaving?: boolean; + defaultConfig?: GameConfig; } export const QuizEditor: React.FC = ({ quiz: initialQuiz, onSave, onStartGame, + onConfigChange, onBack, showSaveButton = true, - isSaving + isSaving, + defaultConfig, }) => { const [quiz, setQuiz] = useState(initialQuiz); const [expandedId, setExpandedId] = useState(null); const [editingQuestion, setEditingQuestion] = useState(null); const [showDeleteConfirm, setShowDeleteConfirm] = useState(null); const [titleEditing, setTitleEditing] = useState(false); - const [shuffleQuestions, setShuffleQuestions] = useState(false); - const [shuffleAnswers, setShuffleAnswers] = useState(false); + const [config, setConfig] = useState( + initialQuiz.config || defaultConfig || DEFAULT_GAME_CONFIG + ); useBodyScrollLock(!!showDeleteConfirm); + const handleConfigChange = useCallback((newConfig: GameConfig) => { + setConfig(newConfig); + onConfigChange?.(newConfig); + }, [onConfigChange]); + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } @@ -117,11 +128,11 @@ export const QuizEditor: React.FC = ({ const handleStartGame = () => { let questions = [...quiz.questions]; - if (shuffleQuestions) { + if (config.shuffleQuestions) { questions = questions.sort(() => Math.random() - 0.5); } - if (shuffleAnswers) { + if (config.shuffleAnswers) { const shapes = ['triangle', 'diamond', 'circle', 'square'] as const; const colors = ['red', 'blue', 'yellow', 'green'] as const; @@ -138,7 +149,7 @@ export const QuizEditor: React.FC = ({ }); } - onStartGame({ ...quiz, questions }); + onStartGame({ ...quiz, questions, config }, config); }; return ( @@ -242,51 +253,12 @@ export const QuizEditor: React.FC = ({
-
- - - -
+