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:
Joey Yakimowich-Payne 2026-01-14 01:43:23 -07:00
commit af21f2bcdc
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
23 changed files with 2925 additions and 133 deletions

View file

@ -16,4 +16,24 @@ db.pragma('foreign_keys = ON');
const schema = readFileSync(join(__dirname, 'schema.sql'), 'utf-8');
db.exec(schema);
const runMigrations = () => {
const tableInfo = db.prepare("PRAGMA table_info(quizzes)").all() as { name: string }[];
const hasGameConfig = tableInfo.some(col => col.name === 'game_config');
if (!hasGameConfig) {
db.exec("ALTER TABLE quizzes ADD COLUMN game_config TEXT");
console.log("Migration: Added game_config to quizzes");
}
const userTableInfo = db.prepare("PRAGMA table_info(users)").all() as { name: string }[];
const hasDefaultConfig = userTableInfo.some(col => col.name === 'default_game_config');
if (!hasDefaultConfig) {
db.exec("ALTER TABLE users ADD COLUMN default_game_config TEXT");
console.log("Migration: Added default_game_config to users");
}
};
runMigrations();
console.log(`Database initialized at ${DB_PATH}`);

View file

@ -0,0 +1,8 @@
-- Migration: Add game_config columns
-- Run these statements to migrate existing databases
-- Add game_config to quizzes table
ALTER TABLE quizzes ADD COLUMN game_config TEXT;
-- Add default_game_config to users table
ALTER TABLE users ADD COLUMN default_game_config TEXT;

View file

@ -4,7 +4,8 @@ CREATE TABLE IF NOT EXISTS users (
email TEXT,
display_name TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME
last_login DATETIME,
default_game_config TEXT
);
CREATE TABLE IF NOT EXISTS quizzes (
@ -13,6 +14,7 @@ CREATE TABLE IF NOT EXISTS quizzes (
title TEXT NOT NULL,
source TEXT NOT NULL CHECK(source IN ('manual', 'ai_generated')),
ai_topic TEXT,
game_config TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)

View file

@ -7,10 +7,26 @@ const router = Router();
router.use(requireAuth);
interface GameConfig {
shuffleQuestions: boolean;
shuffleAnswers: boolean;
hostParticipates: boolean;
streakBonusEnabled: boolean;
streakThreshold: number;
streakMultiplier: number;
comebackBonusEnabled: boolean;
comebackBonusPoints: number;
penaltyForWrongAnswer: boolean;
penaltyPercent: number;
firstCorrectBonusEnabled: boolean;
firstCorrectBonusPoints: number;
}
interface QuizBody {
title: string;
source: 'manual' | 'ai_generated';
aiTopic?: string;
gameConfig?: GameConfig;
questions: {
text: string;
timeLimit?: number;
@ -44,7 +60,7 @@ router.get('/', (req: AuthenticatedRequest, res: Response) => {
router.get('/:id', (req: AuthenticatedRequest, res: Response) => {
const quiz = db.prepare(`
SELECT id, title, source, ai_topic as aiTopic, created_at as createdAt, updated_at as updatedAt
SELECT id, title, source, ai_topic as aiTopic, game_config as gameConfig, created_at as createdAt, updated_at as updatedAt
FROM quizzes
WHERE id = ? AND user_id = ?
`).get(req.params.id, req.user!.sub) as Record<string, unknown> | undefined;
@ -78,8 +94,18 @@ router.get('/:id', (req: AuthenticatedRequest, res: Response) => {
};
});
let parsedConfig = null;
if (quiz.gameConfig && typeof quiz.gameConfig === 'string') {
try {
parsedConfig = JSON.parse(quiz.gameConfig);
} catch {
parsedConfig = null;
}
}
res.json({
...quiz,
gameConfig: parsedConfig,
questions: questionsWithOptions,
});
});
@ -118,7 +144,7 @@ function validateQuizBody(body: QuizBody): string | null {
router.post('/', (req: AuthenticatedRequest, res: Response) => {
const body = req.body as QuizBody;
const { title, source, aiTopic, questions } = body;
const { title, source, aiTopic, gameConfig, questions } = body;
const validationError = validateQuizBody(body);
if (validationError) {
@ -138,8 +164,8 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => {
`);
const insertQuiz = db.prepare(`
INSERT INTO quizzes (id, user_id, title, source, ai_topic)
VALUES (?, ?, ?, ?, ?)
INSERT INTO quizzes (id, user_id, title, source, ai_topic, game_config)
VALUES (?, ?, ?, ?, ?, ?)
`);
const insertQuestion = db.prepare(`
@ -160,7 +186,7 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => {
req.user!.name || null
);
insertQuiz.run(quizId, req.user!.sub, title, source, aiTopic || null);
insertQuiz.run(quizId, req.user!.sub, title, source, aiTopic || null, gameConfig ? JSON.stringify(gameConfig) : null);
questions.forEach((q, qIdx) => {
const questionId = uuidv4();
@ -187,7 +213,7 @@ router.post('/', (req: AuthenticatedRequest, res: Response) => {
router.put('/:id', (req: AuthenticatedRequest, res: Response) => {
const body = req.body as QuizBody;
const { title, questions } = body;
const { title, questions, gameConfig } = body;
const quizId = req.params.id;
if (!title?.trim()) {
@ -227,7 +253,7 @@ router.put('/:id', (req: AuthenticatedRequest, res: Response) => {
}
const updateQuiz = db.prepare(`
UPDATE quizzes SET title = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
UPDATE quizzes SET title = ?, game_config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
`);
const deleteQuestions = db.prepare(`DELETE FROM questions WHERE quiz_id = ?`);
@ -243,7 +269,7 @@ router.put('/:id', (req: AuthenticatedRequest, res: Response) => {
`);
const transaction = db.transaction(() => {
updateQuiz.run(title, quizId);
updateQuiz.run(title, gameConfig ? JSON.stringify(gameConfig) : null, quizId);
deleteQuestions.run(quizId);
questions.forEach((q, qIdx) => {
@ -269,6 +295,26 @@ router.put('/:id', (req: AuthenticatedRequest, res: Response) => {
res.json({ id: quizId });
});
router.patch('/:id/config', (req: AuthenticatedRequest, res: Response) => {
const quizId = req.params.id;
const { gameConfig } = req.body as { gameConfig: GameConfig };
const existing = db.prepare(`
SELECT id FROM quizzes WHERE id = ? AND user_id = ?
`).get(quizId, req.user!.sub);
if (!existing) {
res.status(404).json({ error: 'Quiz not found' });
return;
}
db.prepare(`
UPDATE quizzes SET game_config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?
`).run(gameConfig ? JSON.stringify(gameConfig) : null, quizId);
res.json({ success: true });
});
router.delete('/:id', (req: AuthenticatedRequest, res: Response) => {
const result = db.prepare(`
DELETE FROM quizzes WHERE id = ? AND user_id = ?

View file

@ -8,7 +8,7 @@ router.use(requireAuth);
router.get('/me', (req: AuthenticatedRequest, res: Response) => {
const user = db.prepare(`
SELECT id, username, email, display_name as displayName, created_at as createdAt, last_login as lastLogin
SELECT id, username, email, display_name as displayName, default_game_config as defaultGameConfig, created_at as createdAt, last_login as lastLogin
FROM users
WHERE id = ?
`).get(req.user!.sub) as Record<string, unknown> | undefined;
@ -19,6 +19,7 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
username: req.user!.preferred_username,
email: req.user!.email,
displayName: req.user!.name,
defaultGameConfig: null,
createdAt: null,
lastLogin: null,
isNew: true,
@ -26,7 +27,41 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
return;
}
res.json({ ...user, isNew: false });
let parsedConfig = null;
if (user.defaultGameConfig && typeof user.defaultGameConfig === 'string') {
try {
parsedConfig = JSON.parse(user.defaultGameConfig);
} catch {
parsedConfig = null;
}
}
res.json({ ...user, defaultGameConfig: parsedConfig, isNew: false });
});
router.put('/me/default-config', (req: AuthenticatedRequest, res: Response) => {
const { defaultGameConfig } = req.body;
const upsertUser = db.prepare(`
INSERT INTO users (id, username, email, display_name, default_game_config, last_login)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
default_game_config = ?,
last_login = CURRENT_TIMESTAMP
`);
const configJson = defaultGameConfig ? JSON.stringify(defaultGameConfig) : null;
upsertUser.run(
req.user!.sub,
req.user!.preferred_username,
req.user!.email || null,
req.user!.name || null,
configJson,
configJson
);
res.json({ success: true });
});
export default router;

View file

@ -1257,6 +1257,271 @@ async function runTests() {
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
});
console.log('\nGame Config Tests:');
let gameConfigQuizId: string | null = null;
await test('POST /api/quizzes with gameConfig saves config', async () => {
const quizWithConfig = {
title: 'Quiz With Game Config',
source: 'manual',
gameConfig: {
shuffleQuestions: true,
shuffleAnswers: true,
hostParticipates: false,
streakBonusEnabled: true,
streakThreshold: 5,
streakMultiplier: 1.5,
comebackBonusEnabled: true,
comebackBonusPoints: 100,
penaltyForWrongAnswer: true,
penaltyPercent: 30,
firstCorrectBonusEnabled: true,
firstCorrectBonusPoints: 75,
},
questions: [
{
text: 'Config test question?',
timeLimit: 20,
options: [
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
],
},
],
};
const { data } = await request('POST', '/api/quizzes', quizWithConfig, 201);
gameConfigQuizId = (data as { id: string }).id;
});
await test('GET /api/quizzes/:id returns gameConfig', async () => {
if (!gameConfigQuizId) throw new Error('No game config quiz created');
const { data } = await request('GET', `/api/quizzes/${gameConfigQuizId}`);
const quiz = data as Record<string, unknown>;
if (!quiz.gameConfig) throw new Error('Missing gameConfig');
const config = quiz.gameConfig as Record<string, unknown>;
if (config.shuffleQuestions !== true) throw new Error('shuffleQuestions not preserved');
if (config.shuffleAnswers !== true) throw new Error('shuffleAnswers not preserved');
if (config.hostParticipates !== false) throw new Error('hostParticipates not preserved');
if (config.streakBonusEnabled !== true) throw new Error('streakBonusEnabled not preserved');
if (config.streakThreshold !== 5) throw new Error('streakThreshold not preserved');
if (config.streakMultiplier !== 1.5) throw new Error('streakMultiplier not preserved');
if (config.comebackBonusEnabled !== true) throw new Error('comebackBonusEnabled not preserved');
if (config.comebackBonusPoints !== 100) throw new Error('comebackBonusPoints not preserved');
if (config.penaltyForWrongAnswer !== true) throw new Error('penaltyForWrongAnswer not preserved');
if (config.penaltyPercent !== 30) throw new Error('penaltyPercent not preserved');
if (config.firstCorrectBonusEnabled !== true) throw new Error('firstCorrectBonusEnabled not preserved');
if (config.firstCorrectBonusPoints !== 75) throw new Error('firstCorrectBonusPoints not preserved');
});
await test('PUT /api/quizzes/:id updates gameConfig', async () => {
if (!gameConfigQuizId) throw new Error('No game config quiz created');
const updatedQuiz = {
title: 'Updated Config Quiz',
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,
},
questions: [
{
text: 'Updated question?',
options: [
{ text: 'X', isCorrect: true, shape: 'circle', color: 'yellow' },
{ text: 'Y', isCorrect: false, shape: 'square', color: 'green' },
],
},
],
};
await request('PUT', `/api/quizzes/${gameConfigQuizId}`, updatedQuiz);
const { data } = await request('GET', `/api/quizzes/${gameConfigQuizId}`);
const quiz = data as Record<string, unknown>;
const config = quiz.gameConfig as Record<string, unknown>;
if (config.shuffleQuestions !== false) throw new Error('shuffleQuestions not updated');
if (config.hostParticipates !== true) throw new Error('hostParticipates not updated');
if (config.streakBonusEnabled !== false) throw new Error('streakBonusEnabled not updated');
});
await test('PATCH /api/quizzes/:id/config updates only gameConfig', async () => {
if (!gameConfigQuizId) throw new Error('No game config quiz created');
const newConfig = {
gameConfig: {
shuffleQuestions: true,
shuffleAnswers: true,
hostParticipates: true,
streakBonusEnabled: true,
streakThreshold: 4,
streakMultiplier: 1.3,
comebackBonusEnabled: true,
comebackBonusPoints: 150,
penaltyForWrongAnswer: true,
penaltyPercent: 20,
firstCorrectBonusEnabled: true,
firstCorrectBonusPoints: 100,
},
};
await request('PATCH', `/api/quizzes/${gameConfigQuizId}/config`, newConfig);
const { data } = await request('GET', `/api/quizzes/${gameConfigQuizId}`);
const quiz = data as Record<string, unknown>;
const config = quiz.gameConfig as Record<string, unknown>;
if (config.shuffleQuestions !== true) throw new Error('PATCH did not update shuffleQuestions');
if (config.streakThreshold !== 4) throw new Error('PATCH did not update streakThreshold');
if (config.comebackBonusPoints !== 150) throw new Error('PATCH did not update comebackBonusPoints');
if (quiz.title !== 'Updated Config Quiz') throw new Error('PATCH should not have changed title');
});
await test('PATCH /api/quizzes/:id/config with non-existent ID returns 404', async () => {
const config = {
gameConfig: {
shuffleQuestions: true,
shuffleAnswers: false,
hostParticipates: true,
streakBonusEnabled: false,
streakThreshold: 3,
streakMultiplier: 1.1,
comebackBonusEnabled: false,
comebackBonusPoints: 50,
penaltyForWrongAnswer: false,
penaltyPercent: 25,
firstCorrectBonusEnabled: false,
firstCorrectBonusPoints: 50,
},
};
await request('PATCH', '/api/quizzes/non-existent-id/config', config, 404);
});
await test('PATCH /api/quizzes/:id/config with null gameConfig clears config', async () => {
if (!gameConfigQuizId) throw new Error('No game config quiz created');
await request('PATCH', `/api/quizzes/${gameConfigQuizId}/config`, { gameConfig: null });
const { data } = await request('GET', `/api/quizzes/${gameConfigQuizId}`);
const quiz = data as Record<string, unknown>;
if (quiz.gameConfig !== null) throw new Error('gameConfig should be null after clearing');
});
await test('POST /api/quizzes without gameConfig sets null config', async () => {
const quizNoConfig = {
title: 'Quiz Without Config',
source: 'manual',
questions: [
{
text: 'No config question?',
options: [
{ text: 'A', isCorrect: true, shape: 'triangle', color: 'red' },
{ text: 'B', isCorrect: false, shape: 'diamond', color: 'blue' },
],
},
],
};
const { data } = await request('POST', '/api/quizzes', quizNoConfig, 201);
const quizId = (data as { id: string }).id;
const { data: getResult } = await request('GET', `/api/quizzes/${quizId}`);
const quiz = getResult as Record<string, unknown>;
if (quiz.gameConfig !== null) throw new Error('Expected null gameConfig for quiz without config');
await request('DELETE', `/api/quizzes/${quizId}`, undefined, 204);
});
await test('DELETE cleanup game config quiz', async () => {
if (gameConfigQuizId) {
await request('DELETE', `/api/quizzes/${gameConfigQuizId}`, undefined, 204);
}
});
console.log('\nUser Default Config Tests:');
await test('GET /api/users/me returns defaultGameConfig', async () => {
const { data } = await request('GET', '/api/users/me');
const user = data as Record<string, unknown>;
if (!('defaultGameConfig' in user)) throw new Error('Missing defaultGameConfig field');
});
await test('PUT /api/users/me/default-config saves default config', async () => {
const defaultConfig = {
defaultGameConfig: {
shuffleQuestions: true,
shuffleAnswers: true,
hostParticipates: false,
streakBonusEnabled: true,
streakThreshold: 4,
streakMultiplier: 1.25,
comebackBonusEnabled: true,
comebackBonusPoints: 75,
penaltyForWrongAnswer: true,
penaltyPercent: 15,
firstCorrectBonusEnabled: true,
firstCorrectBonusPoints: 60,
},
};
await request('PUT', '/api/users/me/default-config', defaultConfig);
const { data } = await request('GET', '/api/users/me');
const user = data as Record<string, unknown>;
const config = user.defaultGameConfig as Record<string, unknown>;
if (!config) throw new Error('defaultGameConfig not saved');
if (config.shuffleQuestions !== true) throw new Error('shuffleQuestions not saved');
if (config.streakThreshold !== 4) throw new Error('streakThreshold not saved');
if (config.comebackBonusPoints !== 75) throw new Error('comebackBonusPoints not saved');
});
await test('PUT /api/users/me/default-config with null clears config', async () => {
await request('PUT', '/api/users/me/default-config', { defaultGameConfig: null });
const { data } = await request('GET', '/api/users/me');
const user = data as Record<string, unknown>;
if (user.defaultGameConfig !== null) throw new Error('defaultGameConfig should be null after clearing');
});
await test('PUT /api/users/me/default-config with partial config saves as-is', async () => {
const partialConfig = {
defaultGameConfig: {
shuffleQuestions: true,
hostParticipates: false,
},
};
await request('PUT', '/api/users/me/default-config', partialConfig);
const { data } = await request('GET', '/api/users/me');
const user = data as Record<string, unknown>;
const config = user.defaultGameConfig as Record<string, unknown>;
if (!config) throw new Error('Partial config not saved');
if (config.shuffleQuestions !== true) throw new Error('shuffleQuestions not in partial config');
if (config.hostParticipates !== false) throw new Error('hostParticipates not in partial config');
});
await test('PUT /api/users/me/default-config cleanup - reset to null', async () => {
await request('PUT', '/api/users/me/default-config', { defaultGameConfig: null });
});
console.log('\nPhase 6 - Duplicate/Idempotency Tests:');
await test('POST /api/quizzes with same data creates separate quizzes', async () => {