import { describe, it, expect } from 'vitest'; import { calculateBasePoints, calculatePointsWithBreakdown, getPlayerRank, POINTS_PER_QUESTION, QUESTION_TIME_MS, } from '../constants'; import { DEFAULT_GAME_CONFIG } from '../types'; import type { Player, GameConfig } from '../types'; describe('calculateBasePoints', () => { const questionTimeMs = QUESTION_TIME_MS; const maxPoints = POINTS_PER_QUESTION; describe('happy path - time-based scoring', () => { it('returns max points for instant answer (< 0.5s)', () => { const timeLeftMs = questionTimeMs - 100; // 0.1s response time const points = calculateBasePoints(timeLeftMs, questionTimeMs); expect(points).toBe(maxPoints); }); it('returns max points for exactly 0.5s response', () => { const timeLeftMs = questionTimeMs - 499; const points = calculateBasePoints(timeLeftMs, questionTimeMs); expect(points).toBe(maxPoints); }); it('returns reduced points for slower responses', () => { const timeLeftMs = questionTimeMs / 2; // Half time remaining const points = calculateBasePoints(timeLeftMs, questionTimeMs); expect(points).toBeLessThan(maxPoints); expect(points).toBeGreaterThan(maxPoints / 2); }); it('returns reduced points for last-second answer', () => { const timeLeftMs = 100; const points = calculateBasePoints(timeLeftMs, questionTimeMs); expect(points).toBeGreaterThan(0); expect(points).toBeLessThanOrEqual(510); }); it('calculates points correctly at exactly half time', () => { const timeLeftMs = questionTimeMs / 2; const points = calculateBasePoints(timeLeftMs, questionTimeMs); // At half time, response time is 10s out of 20s // (1 - 10/20 / 2) * 1000 = (1 - 0.25) * 1000 = 750 expect(points).toBe(750); }); }); describe('edge cases', () => { it('handles zero time left gracefully', () => { const points = calculateBasePoints(0, questionTimeMs); expect(points).toBe(500); // (1 - 20/20/2) * 1000 = 500 }); it('handles full time remaining (answered immediately)', () => { const points = calculateBasePoints(questionTimeMs, questionTimeMs); expect(points).toBe(maxPoints); }); it('handles negative time left (should not happen but be safe)', () => { const points = calculateBasePoints(-1000, questionTimeMs); expect(points).toBeLessThan(500); }); it('respects custom maxPoints parameter', () => { const customMax = 500; const points = calculateBasePoints(questionTimeMs, questionTimeMs, customMax); expect(points).toBe(customMax); }); it('handles very short question times', () => { const shortQuestionTime = 5000; // 5 seconds const points = calculateBasePoints(shortQuestionTime, shortQuestionTime); expect(points).toBe(maxPoints); }); }); describe('points calculation formula verification', () => { it('follows linear decay after 0.5s threshold', () => { // Points at 5s response time const points5s = calculateBasePoints(questionTimeMs - 5000, questionTimeMs); // Points at 10s response time const points10s = calculateBasePoints(questionTimeMs - 10000, questionTimeMs); // The decay should be linear const diff1 = maxPoints - points5s; const diff2 = points5s - points10s; expect(Math.abs(diff1 - diff2)).toBeLessThan(10); // Allow small rounding difference }); }); }); describe('calculatePointsWithBreakdown', () => { const baseParams = { isCorrect: true, timeLeftMs: QUESTION_TIME_MS, questionTimeMs: QUESTION_TIME_MS, streak: 0, playerRank: 1, isFirstCorrect: false, config: { ...DEFAULT_GAME_CONFIG }, }; describe('correct answers - happy path', () => { it('returns base points for correct answer with no bonuses', () => { const result = calculatePointsWithBreakdown(baseParams); expect(result.basePoints).toBe(POINTS_PER_QUESTION); expect(result.streakBonus).toBe(0); expect(result.comebackBonus).toBe(0); expect(result.firstCorrectBonus).toBe(0); expect(result.penalty).toBe(0); expect(result.total).toBe(POINTS_PER_QUESTION); }); it('calculates time-based points correctly', () => { const result = calculatePointsWithBreakdown({ ...baseParams, timeLeftMs: QUESTION_TIME_MS / 2, }); expect(result.basePoints).toBe(750); expect(result.total).toBe(750); }); }); describe('incorrect answers', () => { it('returns zero points for incorrect answer with no penalty', () => { const result = calculatePointsWithBreakdown({ ...baseParams, isCorrect: false, config: { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: false }, }); expect(result.basePoints).toBe(0); expect(result.penalty).toBe(0); expect(result.total).toBe(0); }); it('applies penalty for incorrect answer when enabled', () => { const result = calculatePointsWithBreakdown({ ...baseParams, isCorrect: false, config: { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: true, penaltyPercent: 25 }, }); expect(result.basePoints).toBe(0); expect(result.penalty).toBe(250); expect(result.total).toBe(-250); }); it('calculates penalty based on penaltyPercent', () => { const result = calculatePointsWithBreakdown({ ...baseParams, isCorrect: false, config: { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: true, penaltyPercent: 50 }, }); expect(result.penalty).toBe(500); expect(result.total).toBe(-500); }); }); describe('streak bonus', () => { it('applies no streak bonus when disabled', () => { const result = calculatePointsWithBreakdown({ ...baseParams, streak: 5, config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: false }, }); expect(result.streakBonus).toBe(0); }); it('applies no streak bonus below threshold', () => { const result = calculatePointsWithBreakdown({ ...baseParams, streak: 2, // Below default threshold of 3 config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true, streakThreshold: 3 }, }); expect(result.streakBonus).toBe(0); }); it('applies streak bonus at exactly threshold', () => { const result = calculatePointsWithBreakdown({ ...baseParams, streak: 3, config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true, streakThreshold: 3, streakMultiplier: 1.2 }, }); // At threshold, streakCount = 0, so multiplier = 1.2 + (0 * 0.2) = 1.2 // 1000 * 1.2 = 1200, bonus = 200 expect(result.streakBonus).toBe(200); expect(result.total).toBe(1200); }); it('increases streak bonus above threshold', () => { const result = calculatePointsWithBreakdown({ ...baseParams, streak: 5, config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true, streakThreshold: 3, streakMultiplier: 1.2 }, }); // streakCount = 5 - 3 = 2 // multiplier = 1.2 + (2 * 0.2) = 1.6 // 1000 * 1.6 = 1600, bonus = 600 expect(result.streakBonus).toBe(600); expect(result.total).toBe(1600); }); it('compounds streak multiplier correctly', () => { const result = calculatePointsWithBreakdown({ ...baseParams, streak: 10, config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true, streakThreshold: 3, streakMultiplier: 1.1 }, }); // streakCount = 10 - 3 = 7 // multiplier = 1.1 + (7 * 0.1) = 1.8 expect(result.basePoints).toBe(1000); expect(result.total).toBe(1800); }); }); describe('comeback bonus', () => { it('applies no comeback bonus when disabled', () => { const result = calculatePointsWithBreakdown({ ...baseParams, playerRank: 5, currentQuestionIndex: 1, config: { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: false }, }); expect(result.comebackBonus).toBe(0); }); it('applies no comeback bonus for top 3 players', () => { [1, 2, 3].forEach(rank => { const result = calculatePointsWithBreakdown({ ...baseParams, playerRank: rank, currentQuestionIndex: 1, config: { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true, comebackBonusPoints: 100 }, }); expect(result.comebackBonus).toBe(0); }); }); it('applies comeback bonus for 4th place and below', () => { const result = calculatePointsWithBreakdown({ ...baseParams, playerRank: 4, currentQuestionIndex: 1, config: { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true, comebackBonusPoints: 100 }, }); expect(result.comebackBonus).toBe(100); expect(result.total).toBe(1100); }); it('applies same comeback bonus for any rank below 4', () => { const result = calculatePointsWithBreakdown({ ...baseParams, playerRank: 10, currentQuestionIndex: 2, config: { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true, comebackBonusPoints: 150 }, }); expect(result.comebackBonus).toBe(150); }); it('applies no comeback bonus on first question (index 0)', () => { const result = calculatePointsWithBreakdown({ ...baseParams, playerRank: 5, currentQuestionIndex: 0, config: { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true, comebackBonusPoints: 100 }, }); expect(result.comebackBonus).toBe(0); }); it('applies no comeback bonus when currentQuestionIndex is undefined', () => { const result = calculatePointsWithBreakdown({ ...baseParams, playerRank: 5, config: { ...DEFAULT_GAME_CONFIG, comebackBonusEnabled: true, comebackBonusPoints: 100 }, }); expect(result.comebackBonus).toBe(0); }); }); describe('first correct bonus', () => { it('applies no first correct bonus when disabled', () => { const result = calculatePointsWithBreakdown({ ...baseParams, isFirstCorrect: true, config: { ...DEFAULT_GAME_CONFIG, firstCorrectBonusEnabled: false }, }); expect(result.firstCorrectBonus).toBe(0); }); it('applies first correct bonus when enabled and is first', () => { const result = calculatePointsWithBreakdown({ ...baseParams, isFirstCorrect: true, config: { ...DEFAULT_GAME_CONFIG, firstCorrectBonusEnabled: true, firstCorrectBonusPoints: 50 }, }); expect(result.firstCorrectBonus).toBe(50); expect(result.total).toBe(1050); }); it('applies no first correct bonus when not first', () => { const result = calculatePointsWithBreakdown({ ...baseParams, isFirstCorrect: false, config: { ...DEFAULT_GAME_CONFIG, firstCorrectBonusEnabled: true, firstCorrectBonusPoints: 50 }, }); expect(result.firstCorrectBonus).toBe(0); }); }); describe('combined bonuses', () => { it('stacks all bonuses correctly', () => { const config: GameConfig = { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true, streakThreshold: 3, streakMultiplier: 1.2, comebackBonusEnabled: true, comebackBonusPoints: 100, firstCorrectBonusEnabled: true, firstCorrectBonusPoints: 50, }; const result = calculatePointsWithBreakdown({ ...baseParams, streak: 4, playerRank: 5, isFirstCorrect: true, currentQuestionIndex: 2, config, }); expect(result.basePoints).toBe(1000); expect(result.streakBonus).toBeGreaterThan(0); expect(result.comebackBonus).toBe(100); expect(result.firstCorrectBonus).toBe(50); expect(result.total).toBe(result.basePoints + result.streakBonus + result.comebackBonus + result.firstCorrectBonus); }); it('does not apply bonuses to incorrect answers', () => { const config: GameConfig = { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true, streakThreshold: 3, comebackBonusEnabled: true, comebackBonusPoints: 100, firstCorrectBonusEnabled: true, firstCorrectBonusPoints: 50, penaltyForWrongAnswer: true, penaltyPercent: 25, }; const result = calculatePointsWithBreakdown({ ...baseParams, isCorrect: false, streak: 5, playerRank: 5, isFirstCorrect: true, config, }); expect(result.basePoints).toBe(0); expect(result.streakBonus).toBe(0); expect(result.comebackBonus).toBe(0); expect(result.firstCorrectBonus).toBe(0); expect(result.penalty).toBe(250); expect(result.total).toBe(-250); }); }); describe('edge cases', () => { it('handles zero streak threshold', () => { const result = calculatePointsWithBreakdown({ ...baseParams, streak: 1, config: { ...DEFAULT_GAME_CONFIG, streakBonusEnabled: true, streakThreshold: 0, streakMultiplier: 1.5 }, }); // streakCount = 1 - 0 = 1 // multiplier = 1.5 + (1 * 0.5) = 2.0 expect(result.total).toBe(2000); }); it('handles 100% penalty', () => { const result = calculatePointsWithBreakdown({ ...baseParams, isCorrect: false, config: { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: true, penaltyPercent: 100 }, }); expect(result.penalty).toBe(1000); expect(result.total).toBe(-1000); }); it('handles 0% penalty (same as disabled)', () => { const result = calculatePointsWithBreakdown({ ...baseParams, isCorrect: false, config: { ...DEFAULT_GAME_CONFIG, penaltyForWrongAnswer: true, penaltyPercent: 0 }, }); expect(result.penalty).toBe(0); expect(result.total === 0 || Object.is(result.total, -0)).toBe(true); }); }); }); describe('getPlayerRank', () => { const createMockPlayer = (id: string, score: number): Player => ({ id, name: `Player ${id}`, score, previousScore: 0, streak: 0, lastAnswerCorrect: null, selectedShape: null, pointsBreakdown: null, isBot: false, avatarSeed: 0.5, color: '#000000', }); describe('happy path', () => { it('returns 1 for the player with highest score', () => { const players = [ createMockPlayer('a', 100), createMockPlayer('b', 200), createMockPlayer('c', 150), ]; expect(getPlayerRank('b', players)).toBe(1); }); it('returns correct rank for each player', () => { const players = [ createMockPlayer('a', 100), createMockPlayer('b', 200), createMockPlayer('c', 150), ]; expect(getPlayerRank('b', players)).toBe(1); expect(getPlayerRank('c', players)).toBe(2); expect(getPlayerRank('a', players)).toBe(3); }); it('handles single player', () => { const players = [createMockPlayer('a', 100)]; expect(getPlayerRank('a', players)).toBe(1); }); }); describe('tie handling', () => { it('gives same rank position to tied players based on array order', () => { const players = [ createMockPlayer('a', 100), createMockPlayer('b', 100), createMockPlayer('c', 100), ]; // All have same score, order depends on sort stability and original order const rankA = getPlayerRank('a', players); const rankB = getPlayerRank('b', players); const rankC = getPlayerRank('c', players); // All should be valid ranks 1-3 expect([rankA, rankB, rankC].sort()).toEqual([1, 2, 3]); }); it('ranks correctly with some ties', () => { const players = [ createMockPlayer('a', 200), createMockPlayer('b', 100), createMockPlayer('c', 200), createMockPlayer('d', 50), ]; // a and c are tied for first const rankA = getPlayerRank('a', players); const rankC = getPlayerRank('c', players); expect(rankA).toBeLessThanOrEqual(2); expect(rankC).toBeLessThanOrEqual(2); expect(getPlayerRank('b', players)).toBe(3); expect(getPlayerRank('d', players)).toBe(4); }); }); describe('edge cases', () => { it('returns 0 for non-existent player', () => { const players = [ createMockPlayer('a', 100), createMockPlayer('b', 200), ]; // findIndex returns -1 for not found, +1 makes it 0 expect(getPlayerRank('nonexistent', players)).toBe(0); }); it('handles empty players array', () => { expect(getPlayerRank('any', [])).toBe(0); }); it('handles zero scores', () => { const players = [ createMockPlayer('a', 0), createMockPlayer('b', 0), createMockPlayer('c', 100), ]; expect(getPlayerRank('c', players)).toBe(1); // a and b tied at 0 const rankA = getPlayerRank('a', players); const rankB = getPlayerRank('b', players); expect(Math.min(rankA, rankB)).toBe(2); expect(Math.max(rankA, rankB)).toBe(3); }); it('handles negative scores (from penalties)', () => { const players = [ createMockPlayer('a', -100), createMockPlayer('b', 50), createMockPlayer('c', -50), ]; expect(getPlayerRank('b', players)).toBe(1); expect(getPlayerRank('c', players)).toBe(2); expect(getPlayerRank('a', players)).toBe(3); }); it('does not mutate original array', () => { const players = [ createMockPlayer('a', 100), createMockPlayer('b', 200), createMockPlayer('c', 150), ]; const originalOrder = players.map(p => p.id); getPlayerRank('b', players); expect(players.map(p => p.id)).toEqual(originalOrder); }); }); });