Skip to content

Commit 94c5b50

Browse files
authored
feat: separate xp and score rewards (#632)
* Add analytics events tracking endpoint * fix: stop tracking tsbuildinfo/dist, was causing stale CI build errors * Fix TS2339 trackEvent typo, portable filesystem health check, skip-cache flag, jest.fn generics, and flaky memory-based readiness test * Fix TS strict-null errors and add missing PuzzleStatsResult DTO * feat: separate score xp and blockchain rewards
1 parent 670c0c0 commit 94c5b50

9 files changed

Lines changed: 221 additions & 53 deletions

File tree

backend/src/progress/progress.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { GetOverallStatsProvider } from './providers/get-overall-stats.provider'
1212
import { ProgressCalculationProvider } from './providers/progress-calculation.provider';
1313
import { Puzzle } from '../puzzles/entities/puzzle.entity';
1414
import { XpLevelService } from '../users/providers/xp-level.service';
15+
import { ScoreService } from '../score/providers/score.service';
1516

1617
@Module({
1718
imports: [
@@ -25,6 +26,7 @@ import { XpLevelService } from '../users/providers/xp-level.service';
2526
GetOverallStatsProvider,
2627
ProgressCalculationProvider,
2728
XpLevelService,
29+
ScoreService,
2830
],
2931
exports: [ProgressService, ProgressCalculationProvider],
3032
})

backend/src/progress/providers/progress-calculation.provider.ts

Lines changed: 32 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { XpLevelService } from '../../users/providers/xp-level.service';
88
import { User } from '../../users/user.entity';
99
import { DailyQuest } from '../../quests/entities/daily-quest.entity';
1010
import { getPointsByDifficulty } from '../../puzzles/enums/puzzle-difficulty.enum';
11+
import { ScoreService } from '../../score/providers/score.service';
12+
1113

1214
export interface AnswerValidationResult {
1315
isCorrect: boolean;
@@ -32,6 +34,7 @@ export class ProgressCalculationProvider {
3234
private readonly userRepository: Repository<User>,
3335
@InjectRepository(DailyQuest)
3436
private readonly dailyQuestRepository: Repository<DailyQuest>,
37+
private readonly scoreService: ScoreService,
3538
) {}
3639

3740
/**
@@ -84,20 +87,7 @@ export class ProgressCalculationProvider {
8487
);
8588
}
8689

87-
/**
88-
* Calculates level based on total XP
89-
*/
90-
calculateLevel(totalXP: number): number {
91-
if (totalXP < 1000) return 1;
92-
if (totalXP < 2500) return 2;
93-
if (totalXP < 5000) return 3;
94-
if (totalXP < 10000) return 4;
95-
96-
// Level 5+: Exponential scaling: 10000 + (level - 4) * some_growth
97-
// Simplified: level 5 starts at 10000, each level after adds 5000+
98-
return Math.floor((totalXP - 10000) / 5000) + 5;
99-
}
100-
90+
10191
/**
10292
* Processes answer submission and creates user progress record
10393
*/
@@ -135,11 +125,18 @@ export class ProgressCalculationProvider {
135125
);
136126

137127
// Calculate points
138-
let pointsEarned = this.calculatePoints(
139-
puzzle,
140-
submitAnswerDto.timeSpent,
141-
validation.isCorrect,
142-
);
128+
const basePoints = this.calculatePoints(
129+
puzzle,
130+
submitAnswerDto.timeSpent,
131+
validation.isCorrect,
132+
);
133+
134+
const scoreResult = this.scoreService.calculateScore({
135+
correct: validation.isCorrect,
136+
basePoints,
137+
});
138+
139+
let pointsEarned = scoreResult.score;
143140

144141
// Fetch user and apply streak bonus
145142
const user = await this.userRepository.findOne({
@@ -148,20 +145,20 @@ export class ProgressCalculationProvider {
148145
});
149146

150147
if (user && validation.isCorrect) {
151-
const streakCount = user.streak?.currentStreak || 0;
152-
let streakMultiplier = 0;
153-
if (streakCount >= 7) {
154-
streakMultiplier = 0.25;
155-
} else if (streakCount >= 3) {
156-
streakMultiplier = 0.1;
157-
}
158-
pointsEarned = Math.round(pointsEarned * (1 + streakMultiplier));
148+
const streakCount = user.streak?.currentStreak || 0;
159149

160-
// Update User XP and Level
161-
user.xp += pointsEarned;
162-
user.level = this.calculateLevel(user.xp);
163-
await this.userRepository.save(user);
164-
}
150+
let streakMultiplier = 0;
151+
152+
if (streakCount >= 7) {
153+
streakMultiplier = 0.25;
154+
} else if (streakCount >= 3) {
155+
streakMultiplier = 0.1;
156+
}
157+
158+
pointsEarned = Math.round(
159+
pointsEarned * (1 + streakMultiplier),
160+
);
161+
}
165162

166163
validation.pointsEarned = pointsEarned;
167164

@@ -195,12 +192,10 @@ export class ProgressCalculationProvider {
195192
dailyQuest.completedAt = new Date();
196193
// Award bonus XP for daily quest completion (e.g., 50 XP as hinted in "completion screen")
197194
if (user) {
198-
user.xp += 50;
199-
user.level = this.calculateLevel(user.xp);
200-
await this.userRepository.save(user);
195+
await this.xpLevelService.addXp(user.id, 50);
196+
}
201197
}
202-
}
203-
await this.dailyQuestRepository.save(dailyQuest);
198+
await this.dailyQuestRepository.save(dailyQuest);
204199
}
205200
}
206201
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export interface RewardEligibilityInput {
2+
score: number;
3+
xp: number;
4+
correct: boolean;
5+
}
6+
7+
export interface RewardEligibilityResult {
8+
eligible: boolean;
9+
reason: string;
10+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { RewardService } from './reward.service';
2+
import { beforeEach, describe, expect, it } from '@jest/globals';
3+
4+
describe('RewardService', () => {
5+
let service: RewardService;
6+
7+
beforeEach(() => {
8+
service = new RewardService();
9+
});
10+
11+
it('should mark a player eligible when requirements are met', () => {
12+
const result = service.checkEligibility({
13+
correct: true,
14+
score: 100,
15+
xp: 100,
16+
});
17+
18+
expect(result.eligible).toBe(true);
19+
});
20+
21+
it('should reject an incorrect answer', () => {
22+
const result = service.checkEligibility({
23+
correct: false,
24+
score: 100,
25+
xp: 100,
26+
});
27+
28+
expect(result.eligible).toBe(false);
29+
expect(result.reason).toBe('Answer was incorrect');
30+
});
31+
32+
it('should reject a score below the reward threshold', () => {
33+
const result = service.checkEligibility({
34+
correct: true,
35+
score: 50,
36+
xp: 50,
37+
});
38+
39+
expect(result.eligible).toBe(false);
40+
expect(result.reason).toBe('Score is below the reward threshold');
41+
});
42+
43+
it('should not depend on blockchain availability', () => {
44+
const result = service.checkEligibility({
45+
correct: true,
46+
score: 100,
47+
xp: 100,
48+
});
49+
50+
expect(result.eligible).toBe(true);
51+
});
52+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Injectable } from '@nestjs/common';
2+
import {
3+
RewardEligibilityInput,
4+
RewardEligibilityResult,
5+
} from '../interfaces/reward.interface';
6+
7+
@Injectable()
8+
export class RewardService {
9+
private readonly MINIMUM_SCORE_FOR_REWARD = 100;
10+
11+
checkEligibility(
12+
input: RewardEligibilityInput,
13+
): RewardEligibilityResult {
14+
if (!input.correct) {
15+
return {
16+
eligible: false,
17+
reason: 'Answer was incorrect',
18+
};
19+
}
20+
21+
if (input.score < this.MINIMUM_SCORE_FOR_REWARD) {
22+
return {
23+
eligible: false,
24+
reason: 'Score is below the reward threshold',
25+
};
26+
}
27+
28+
return {
29+
eligible: true,
30+
reason: 'Player meets reward eligibility requirements',
31+
};
32+
}
33+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export interface ScoreCalculationInput {
2+
correct: boolean;
3+
basePoints: number;
4+
attempts?: number;
5+
timeTakenSeconds?: number;
6+
}
7+
8+
export interface ScoreCalculationResult {
9+
score: number;
10+
correct: boolean;
11+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { ScoreService } from './score.service';
2+
import { beforeEach, describe, expect, it } from '@jest/globals';
3+
4+
describe('ScoreService', () => {
5+
let service: ScoreService;
6+
7+
beforeEach(() => {
8+
service = new ScoreService();
9+
});
10+
11+
it('should award base score for a correct answer', () => {
12+
const result = service.calculateScore({
13+
correct: true,
14+
basePoints: 100,
15+
});
16+
17+
expect(result.score).toBe(100);
18+
expect(result.correct).toBe(true);
19+
});
20+
21+
it('should award zero score for an incorrect answer', () => {
22+
const result = service.calculateScore({
23+
correct: false,
24+
basePoints: 100,
25+
});
26+
27+
expect(result.score).toBe(0);
28+
expect(result.correct).toBe(false);
29+
});
30+
31+
it('should never produce a negative score', () => {
32+
const result = service.calculateScore({
33+
correct: true,
34+
basePoints: -10,
35+
});
36+
37+
expect(result.score).toBe(0);
38+
});
39+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Injectable } from '@nestjs/common';
2+
import {
3+
ScoreCalculationInput,
4+
ScoreCalculationResult,
5+
} from '../interfaces/score.interface';
6+
7+
@Injectable()
8+
export class ScoreService {
9+
calculateScore(
10+
input: ScoreCalculationInput,
11+
): ScoreCalculationResult {
12+
if (!input.correct) {
13+
return {
14+
score: 0,
15+
correct: false,
16+
};
17+
}
18+
19+
const score = Math.max(0, input.basePoints);
20+
21+
return {
22+
score,
23+
correct: true,
24+
};
25+
}
26+
}

backend/test/analytics.e2e-spec.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -201,22 +201,22 @@ describe('GET /analytics/users/retention (e2e)', () => {
201201
describe('GET /analytics/puzzles/:id/stats (e2e)', () => {
202202
let app: INestApplication<App>;
203203

204-
const fakePuzzleStatsResult: PuzzleStatsResult = {
205-
puzzleId: 'puzzle-uuid-100',
206-
totalAttempts: 10,
207-
successfulAttempts: 7,
208-
failedAttempts: 3,
209-
successRate: 70,
210-
averageTimeSpent: 45.2,
211-
uniqueUsers: 6,
212-
startDate: '2024-01-01',
213-
endDate: '2024-01-31',
214-
};
215-
216-
const mockPuzzleAnalyticsProvider = {
217-
// return a resolved promise with the fake result
218-
getPuzzleStats: jest.fn<() => Promise<PuzzleStatsResult>>().mockResolvedValue(fakePuzzleStatsResult),
219-
};
204+
const fakePuzzleStatsResult: PuzzleStatsResult = {
205+
puzzleId: 'puzzle-uuid-100',
206+
totalAttempts: 10,
207+
successfulAttempts: 7,
208+
failedAttempts: 3,
209+
successRate: 70,
210+
averageTimeSpent: 45.2,
211+
uniqueUsers: 6,
212+
startDate: '2024-01-01',
213+
endDate: '2024-01-31',
214+
};
215+
216+
const mockPuzzleAnalyticsProvider = {
217+
// return a resolved promise with the fake result
218+
getPuzzleStats: jest.fn<() => Promise<PuzzleStatsResult>>().mockResolvedValue(fakePuzzleStatsResult),
219+
};
220220

221221
beforeAll(async () => {
222222
const moduleFixture: TestingModule = await Test.createTestingModule({

0 commit comments

Comments
 (0)