Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/src/progress/progress.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -25,6 +26,7 @@ import { XpLevelService } from '../users/providers/xp-level.service';
GetOverallStatsProvider,
ProgressCalculationProvider,
XpLevelService,
ScoreService,
],
exports: [ProgressService, ProgressCalculationProvider],
})
Expand Down
69 changes: 32 additions & 37 deletions backend/src/progress/providers/progress-calculation.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,6 +34,7 @@ export class ProgressCalculationProvider {
private readonly userRepository: Repository<User>,
@InjectRepository(DailyQuest)
private readonly dailyQuestRepository: Repository<DailyQuest>,
private readonly scoreService: ScoreService,
) {}

/**
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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({
Expand All @@ -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;

Expand Down Expand Up @@ -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);
}
}
}
Expand Down
10 changes: 10 additions & 0 deletions backend/src/rewards/interfaces/reward.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export interface RewardEligibilityInput {
score: number;
xp: number;
correct: boolean;
}

export interface RewardEligibilityResult {
eligible: boolean;
reason: string;
}
52 changes: 52 additions & 0 deletions backend/src/rewards/providers/reward.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
33 changes: 33 additions & 0 deletions backend/src/rewards/providers/reward.service.ts
Original file line number Diff line number Diff line change
@@ -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',
};
}
}
11 changes: 11 additions & 0 deletions backend/src/score/interfaces/score.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface ScoreCalculationInput {
correct: boolean;
basePoints: number;
attempts?: number;
timeTakenSeconds?: number;
}

export interface ScoreCalculationResult {
score: number;
correct: boolean;
}
39 changes: 39 additions & 0 deletions backend/src/score/providers/score.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
26 changes: 26 additions & 0 deletions backend/src/score/providers/score.service.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
}
32 changes: 16 additions & 16 deletions backend/test/analytics.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,22 +201,22 @@ describe('GET /analytics/users/retention (e2e)', () => {
describe('GET /analytics/puzzles/:id/stats (e2e)', () => {
let app: INestApplication<App>;

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<PuzzleStatsResult>>().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<PuzzleStatsResult>>().mockResolvedValue(fakePuzzleStatsResult),
};

beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
Expand Down
Loading