diff --git a/backend/src/progress/progress.module.ts b/backend/src/progress/progress.module.ts index d8da0feb..8b7c4aaa 100644 --- a/backend/src/progress/progress.module.ts +++ b/backend/src/progress/progress.module.ts @@ -12,6 +12,7 @@ import { GetOverallStatsProvider } from './providers/get-overall-stats.provider' import { ProgressCalculationProvider } from './providers/progress-calculation.provider'; import { Puzzle } from '../puzzles/entities/puzzle.entity'; import { XpLevelService } from '../users/providers/xp-level.service'; +import { ScoreService } from '../score/providers/score.service'; @Module({ imports: [ @@ -25,6 +26,7 @@ import { XpLevelService } from '../users/providers/xp-level.service'; GetOverallStatsProvider, ProgressCalculationProvider, XpLevelService, + ScoreService, ], exports: [ProgressService, ProgressCalculationProvider], }) diff --git a/backend/src/progress/providers/progress-calculation.provider.ts b/backend/src/progress/providers/progress-calculation.provider.ts index 6e8d5141..0d572286 100644 --- a/backend/src/progress/providers/progress-calculation.provider.ts +++ b/backend/src/progress/providers/progress-calculation.provider.ts @@ -8,6 +8,8 @@ import { XpLevelService } from '../../users/providers/xp-level.service'; import { User } from '../../users/user.entity'; import { DailyQuest } from '../../quests/entities/daily-quest.entity'; import { getPointsByDifficulty } from '../../puzzles/enums/puzzle-difficulty.enum'; +import { ScoreService } from '../../score/providers/score.service'; + export interface AnswerValidationResult { isCorrect: boolean; @@ -32,6 +34,7 @@ export class ProgressCalculationProvider { private readonly userRepository: Repository, @InjectRepository(DailyQuest) private readonly dailyQuestRepository: Repository, + private readonly scoreService: ScoreService, ) {} /** @@ -84,20 +87,7 @@ export class ProgressCalculationProvider { ); } - /** - * Calculates level based on total XP - */ - calculateLevel(totalXP: number): number { - if (totalXP < 1000) return 1; - if (totalXP < 2500) return 2; - if (totalXP < 5000) return 3; - if (totalXP < 10000) return 4; - - // Level 5+: Exponential scaling: 10000 + (level - 4) * some_growth - // Simplified: level 5 starts at 10000, each level after adds 5000+ - return Math.floor((totalXP - 10000) / 5000) + 5; - } - + /** * Processes answer submission and creates user progress record */ @@ -135,11 +125,18 @@ export class ProgressCalculationProvider { ); // Calculate points - let pointsEarned = this.calculatePoints( - puzzle, - submitAnswerDto.timeSpent, - validation.isCorrect, - ); + const basePoints = this.calculatePoints( + puzzle, + submitAnswerDto.timeSpent, + validation.isCorrect, +); + + const scoreResult = this.scoreService.calculateScore({ + correct: validation.isCorrect, + basePoints, + }); + + let pointsEarned = scoreResult.score; // Fetch user and apply streak bonus const user = await this.userRepository.findOne({ @@ -148,20 +145,20 @@ export class ProgressCalculationProvider { }); if (user && validation.isCorrect) { - const streakCount = user.streak?.currentStreak || 0; - let streakMultiplier = 0; - if (streakCount >= 7) { - streakMultiplier = 0.25; - } else if (streakCount >= 3) { - streakMultiplier = 0.1; - } - pointsEarned = Math.round(pointsEarned * (1 + streakMultiplier)); + const streakCount = user.streak?.currentStreak || 0; - // Update User XP and Level - user.xp += pointsEarned; - user.level = this.calculateLevel(user.xp); - await this.userRepository.save(user); - } + let streakMultiplier = 0; + + if (streakCount >= 7) { + streakMultiplier = 0.25; + } else if (streakCount >= 3) { + streakMultiplier = 0.1; + } + + pointsEarned = Math.round( + pointsEarned * (1 + streakMultiplier), + ); + } validation.pointsEarned = pointsEarned; @@ -195,12 +192,10 @@ export class ProgressCalculationProvider { dailyQuest.completedAt = new Date(); // Award bonus XP for daily quest completion (e.g., 50 XP as hinted in "completion screen") if (user) { - user.xp += 50; - user.level = this.calculateLevel(user.xp); - await this.userRepository.save(user); + await this.xpLevelService.addXp(user.id, 50); +} } - } - await this.dailyQuestRepository.save(dailyQuest); + await this.dailyQuestRepository.save(dailyQuest); } } } diff --git a/backend/src/rewards/interfaces/reward.interface.ts b/backend/src/rewards/interfaces/reward.interface.ts new file mode 100644 index 00000000..e0946298 --- /dev/null +++ b/backend/src/rewards/interfaces/reward.interface.ts @@ -0,0 +1,10 @@ +export interface RewardEligibilityInput { + score: number; + xp: number; + correct: boolean; +} + +export interface RewardEligibilityResult { + eligible: boolean; + reason: string; +} \ No newline at end of file diff --git a/backend/src/rewards/providers/reward.service.spec.ts b/backend/src/rewards/providers/reward.service.spec.ts new file mode 100644 index 00000000..f0441372 --- /dev/null +++ b/backend/src/rewards/providers/reward.service.spec.ts @@ -0,0 +1,52 @@ +import { RewardService } from './reward.service'; +import { beforeEach, describe, expect, it } from '@jest/globals'; + +describe('RewardService', () => { + let service: RewardService; + + beforeEach(() => { + service = new RewardService(); + }); + + it('should mark a player eligible when requirements are met', () => { + const result = service.checkEligibility({ + correct: true, + score: 100, + xp: 100, + }); + + expect(result.eligible).toBe(true); + }); + + it('should reject an incorrect answer', () => { + const result = service.checkEligibility({ + correct: false, + score: 100, + xp: 100, + }); + + expect(result.eligible).toBe(false); + expect(result.reason).toBe('Answer was incorrect'); + }); + + it('should reject a score below the reward threshold', () => { + const result = service.checkEligibility({ + correct: true, + score: 50, + xp: 50, + }); + + expect(result.eligible).toBe(false); + expect(result.reason).toBe('Score is below the reward threshold'); + }); + + it('should not depend on blockchain availability', () => { + const result = service.checkEligibility({ + correct: true, + score: 100, + xp: 100, + }); + + expect(result.eligible).toBe(true); + }); +}); \ No newline at end of file diff --git a/backend/src/rewards/providers/reward.service.ts b/backend/src/rewards/providers/reward.service.ts new file mode 100644 index 00000000..b4f92cb0 --- /dev/null +++ b/backend/src/rewards/providers/reward.service.ts @@ -0,0 +1,33 @@ +import { Injectable } from '@nestjs/common'; +import { + RewardEligibilityInput, + RewardEligibilityResult, +} from '../interfaces/reward.interface'; + +@Injectable() +export class RewardService { + private readonly MINIMUM_SCORE_FOR_REWARD = 100; + + checkEligibility( + input: RewardEligibilityInput, + ): RewardEligibilityResult { + if (!input.correct) { + return { + eligible: false, + reason: 'Answer was incorrect', + }; + } + + if (input.score < this.MINIMUM_SCORE_FOR_REWARD) { + return { + eligible: false, + reason: 'Score is below the reward threshold', + }; + } + + return { + eligible: true, + reason: 'Player meets reward eligibility requirements', + }; + } +} \ No newline at end of file diff --git a/backend/src/score/interfaces/score.interface.ts b/backend/src/score/interfaces/score.interface.ts new file mode 100644 index 00000000..10eecf0d --- /dev/null +++ b/backend/src/score/interfaces/score.interface.ts @@ -0,0 +1,11 @@ +export interface ScoreCalculationInput { + correct: boolean; + basePoints: number; + attempts?: number; + timeTakenSeconds?: number; +} + +export interface ScoreCalculationResult { + score: number; + correct: boolean; +} \ No newline at end of file diff --git a/backend/src/score/providers/score.service.spec.ts b/backend/src/score/providers/score.service.spec.ts new file mode 100644 index 00000000..9207e123 --- /dev/null +++ b/backend/src/score/providers/score.service.spec.ts @@ -0,0 +1,39 @@ +import { ScoreService } from './score.service'; +import { beforeEach, describe, expect, it } from '@jest/globals'; + +describe('ScoreService', () => { + let service: ScoreService; + + beforeEach(() => { + service = new ScoreService(); + }); + + it('should award base score for a correct answer', () => { + const result = service.calculateScore({ + correct: true, + basePoints: 100, + }); + + expect(result.score).toBe(100); + expect(result.correct).toBe(true); + }); + + it('should award zero score for an incorrect answer', () => { + const result = service.calculateScore({ + correct: false, + basePoints: 100, + }); + + expect(result.score).toBe(0); + expect(result.correct).toBe(false); + }); + + it('should never produce a negative score', () => { + const result = service.calculateScore({ + correct: true, + basePoints: -10, + }); + + expect(result.score).toBe(0); + }); +}); \ No newline at end of file diff --git a/backend/src/score/providers/score.service.ts b/backend/src/score/providers/score.service.ts new file mode 100644 index 00000000..321acbd1 --- /dev/null +++ b/backend/src/score/providers/score.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from '@nestjs/common'; +import { + ScoreCalculationInput, + ScoreCalculationResult, +} from '../interfaces/score.interface'; + +@Injectable() +export class ScoreService { + calculateScore( + input: ScoreCalculationInput, + ): ScoreCalculationResult { + if (!input.correct) { + return { + score: 0, + correct: false, + }; + } + + const score = Math.max(0, input.basePoints); + + return { + score, + correct: true, + }; + } +} \ No newline at end of file diff --git a/backend/test/analytics.e2e-spec.ts b/backend/test/analytics.e2e-spec.ts index 5fac7fb7..8301e1d2 100644 --- a/backend/test/analytics.e2e-spec.ts +++ b/backend/test/analytics.e2e-spec.ts @@ -201,22 +201,22 @@ describe('GET /analytics/users/retention (e2e)', () => { describe('GET /analytics/puzzles/:id/stats (e2e)', () => { let app: INestApplication; - const fakePuzzleStatsResult: PuzzleStatsResult = { - puzzleId: 'puzzle-uuid-100', - totalAttempts: 10, - successfulAttempts: 7, - failedAttempts: 3, - successRate: 70, - averageTimeSpent: 45.2, - uniqueUsers: 6, - startDate: '2024-01-01', - endDate: '2024-01-31', - }; - - const mockPuzzleAnalyticsProvider = { - // return a resolved promise with the fake result - getPuzzleStats: jest.fn<() => Promise>().mockResolvedValue(fakePuzzleStatsResult), - }; +const fakePuzzleStatsResult: PuzzleStatsResult = { + puzzleId: 'puzzle-uuid-100', + totalAttempts: 10, + successfulAttempts: 7, + failedAttempts: 3, + successRate: 70, + averageTimeSpent: 45.2, + uniqueUsers: 6, + startDate: '2024-01-01', + endDate: '2024-01-31', +}; + +const mockPuzzleAnalyticsProvider = { + // return a resolved promise with the fake result + getPuzzleStats: jest.fn<() => Promise>().mockResolvedValue(fakePuzzleStatsResult), +}; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({