565 lines
18 KiB
TypeScript
565 lines
18 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|