From 77c4ab02f2d2e8363e358ad1edba1c7331d5c429 Mon Sep 17 00:00:00 2001 From: dotandev Date: Thu, 29 Jan 2026 12:50:03 +0100 Subject: [PATCH] feat(points): implement robust points and XP calculation system --- .../src/progress/entities/progress.entity.ts | 12 +- backend/src/progress/progress.module.ts | 13 +- .../progress-calculation.provider.ts | 118 +++++++++++++++--- .../puzzles/controllers/puzzles.controller.ts | 2 +- .../puzzles/enums/puzzle-difficulty.enum.ts | 8 +- backend/src/puzzles/puzzles.module.ts | 2 +- backend/src/users/dtos/editUserDto.dto.ts | 2 +- .../users/providers/update-user.service.ts | 2 +- backend/src/users/user.entity.ts | 37 +++++- backend/src/users/users.module.ts | 2 +- 10 files changed, 163 insertions(+), 35 deletions(-) diff --git a/backend/src/progress/entities/progress.entity.ts b/backend/src/progress/entities/progress.entity.ts index be48030..4b7d648 100644 --- a/backend/src/progress/entities/progress.entity.ts +++ b/backend/src/progress/entities/progress.entity.ts @@ -20,8 +20,8 @@ export class UserProgress { @PrimaryGeneratedColumn() id: number; - @Column() - userId: number; + @Column('uuid') + userId: string; @ManyToOne(() => User, (user) => user.progressRecords, { onDelete: 'CASCADE', @@ -29,8 +29,8 @@ export class UserProgress { @JoinColumn({ name: 'userId' }) user: User; - @Column() - puzzleId: number; + @Column('uuid') + puzzleId: string; @ManyToOne(() => Puzzle, (puzzle) => puzzle.progressRecords, { onDelete: 'CASCADE', @@ -38,8 +38,8 @@ export class UserProgress { @JoinColumn({ name: 'puzzleId' }) puzzle: Puzzle; - @Column() - categoryId: number; + @Column('uuid') + categoryId: string; @ManyToOne(() => Category, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'categoryId' }) diff --git a/backend/src/progress/progress.module.ts b/backend/src/progress/progress.module.ts index a2f7397..74202c2 100644 --- a/backend/src/progress/progress.module.ts +++ b/backend/src/progress/progress.module.ts @@ -1,9 +1,18 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { UserProgress } from './entities/progress.entity'; +import { User } from '../users/user.entity'; +import { Puzzle } from '../puzzles/entities/puzzle.entity'; +import { Streak } from '../streak/entities/streak.entity'; +import { DailyQuest } from '../quests/entities/daily-quest.entity'; +import { ProgressService } from './progress.service'; +import { ProgressCalculationProvider } from './providers/progress-calculation.provider'; @Module({ - imports: [TypeOrmModule.forFeature([UserProgress])], - exports: [TypeOrmModule], + imports: [ + TypeOrmModule.forFeature([UserProgress, User, Puzzle, Streak, DailyQuest]), + ], + providers: [ProgressService, ProgressCalculationProvider], + exports: [ProgressService, ProgressCalculationProvider, TypeOrmModule], }) export class ProgressModule {} diff --git a/backend/src/progress/providers/progress-calculation.provider.ts b/backend/src/progress/providers/progress-calculation.provider.ts index 4f564e7..76c7d18 100644 --- a/backend/src/progress/providers/progress-calculation.provider.ts +++ b/backend/src/progress/providers/progress-calculation.provider.ts @@ -2,8 +2,12 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { MoreThan, Repository } from 'typeorm'; import { Puzzle } from '../../puzzles/entities/puzzle.entity'; -import { UserProgress } from '../entities/user-progress.entity'; +import { UserProgress } from '../entities/progress.entity'; import { SubmitAnswerDto } from '../dtos/submit-answer.dto'; +import { User } from '../../users/user.entity'; +import { Streak } from '../../streak/entities/streak.entity'; +import { DailyQuest } from '../../quests/entities/daily-quest.entity'; +import { getPointsByDifficulty } from '../../puzzles/enums/puzzle-difficulty.enum'; export interface AnswerValidationResult { isCorrect: boolean; @@ -30,6 +34,12 @@ export class ProgressCalculationProvider { private readonly puzzleRepository: Repository, @InjectRepository(UserProgress) private readonly userProgressRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + @InjectRepository(Streak) + private readonly streakRepository: Repository, + @InjectRepository(DailyQuest) + private readonly dailyQuestRepository: Repository, ) {} /** @@ -65,25 +75,35 @@ export class ProgressCalculationProvider { return 0; } - const basePoints = puzzle.points; + const basePoints = getPointsByDifficulty(puzzle.difficulty); const timeLimit = puzzle.timeLimit; - // Time bonus: up to 20% extra points for fast completion - // Time penalty: up to 10% reduction for slow completion - let timeMultiplier = 1.0; - - if (timeSpent <= timeLimit * 0.5) { - // Completed in half the time or less - 20% bonus - timeMultiplier = 1.2; - } else if (timeSpent <= timeLimit * 0.75) { - // Completed in 75% of time or less - 10% bonus - timeMultiplier = 1.1; - } else if (timeSpent > timeLimit) { - // Exceeded time limit - 10% penalty - timeMultiplier = 0.9; + // Time bonus: (timeLimit - timeSpent) / timeLimit * 0.5 (max 0.5 bonus) + let timeBonusMultiplier = 0; + if (timeSpent < timeLimit) { + timeBonusMultiplier = ((timeLimit - timeSpent) / timeLimit) * 0.5; } - return Math.round(basePoints * timeMultiplier); + // Accuracy multiplier (currently 1.0 for correct, 0.0 for incorrect) + const accuracyMultiplier = 1.0; + + return Math.round( + basePoints * (1 + timeBonusMultiplier) * accuracyMultiplier, + ); + } + + /** + * 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; } /** @@ -123,18 +143,82 @@ export class ProgressCalculationProvider { ); // Calculate points - const pointsEarned = this.calculatePoints( + let pointsEarned = this.calculatePoints( puzzle, submitAnswerDto.timeSpent, validation.isCorrect, ); + + // Fetch user and apply streak bonus + const user = await this.userRepository.findOne({ + where: { id: submitAnswerDto.userId }, + relations: ['streak'], + }); + + 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)); + + // Update User XP and Level + user.xp += pointsEarned; + user.level = this.calculateLevel(user.xp); + await this.userRepository.save(user); + } + validation.pointsEarned = pointsEarned; + // Check for Daily Quest completion + const todayDate = new Date().toISOString().split('T')[0]; + const dailyQuest = await this.dailyQuestRepository.findOne({ + where: { userId: submitAnswerDto.userId, questDate: todayDate }, + relations: ['questPuzzles'], + }); + + if (dailyQuest && !dailyQuest.isCompleted) { + const isQuestPuzzle = dailyQuest.questPuzzles.some( + (qp) => qp.puzzleId === submitAnswerDto.puzzleId, + ); + + if (isQuestPuzzle && validation.isCorrect) { + // Double check if this puzzle was already completed today for this quest + const alreadyCompleted = await this.userProgressRepository.findOne({ + where: { + userId: submitAnswerDto.userId, + puzzleId: submitAnswerDto.puzzleId, + dailyQuestId: dailyQuest.id, + isCorrect: true, + }, + }); + + if (!alreadyCompleted) { + dailyQuest.completedQuestions += 1; + if (dailyQuest.completedQuestions >= dailyQuest.totalQuestions) { + dailyQuest.isCompleted = true; + 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.dailyQuestRepository.save(dailyQuest); + } + } + } + // Create user progress record const userProgress = this.userProgressRepository.create({ userId: submitAnswerDto.userId, puzzleId: submitAnswerDto.puzzleId, categoryId: submitAnswerDto.categoryId, + dailyQuestId: dailyQuest?.id, isCorrect: validation.isCorrect, userAnswer: submitAnswerDto.userAnswer, pointsEarned, diff --git a/backend/src/puzzles/controllers/puzzles.controller.ts b/backend/src/puzzles/controllers/puzzles.controller.ts index 8aee050..064f0da 100644 --- a/backend/src/puzzles/controllers/puzzles.controller.ts +++ b/backend/src/puzzles/controllers/puzzles.controller.ts @@ -60,4 +60,4 @@ export class PuzzlesController { findAll(@Query() query: PuzzleQueryDto) { return this.puzzlesService.findAll(query); } -} \ No newline at end of file +} diff --git a/backend/src/puzzles/enums/puzzle-difficulty.enum.ts b/backend/src/puzzles/enums/puzzle-difficulty.enum.ts index 8a9d9b4..fbbdf63 100644 --- a/backend/src/puzzles/enums/puzzle-difficulty.enum.ts +++ b/backend/src/puzzles/enums/puzzle-difficulty.enum.ts @@ -8,10 +8,10 @@ export enum PuzzleDifficulty { // Helper function to get points based on difficulty export function getPointsByDifficulty(difficulty: PuzzleDifficulty): number { const pointsMap: Record = { - [PuzzleDifficulty.BEGINNER]: 100, - [PuzzleDifficulty.INTERMEDIATE]: 250, - [PuzzleDifficulty.ADVANCED]: 500, - [PuzzleDifficulty.EXPERT]: 1000, + [PuzzleDifficulty.BEGINNER]: 10, + [PuzzleDifficulty.INTERMEDIATE]: 25, + [PuzzleDifficulty.ADVANCED]: 50, + [PuzzleDifficulty.EXPERT]: 100, }; return pointsMap[difficulty]; } diff --git a/backend/src/puzzles/puzzles.module.ts b/backend/src/puzzles/puzzles.module.ts index 539334e..276d4f5 100644 --- a/backend/src/puzzles/puzzles.module.ts +++ b/backend/src/puzzles/puzzles.module.ts @@ -13,4 +13,4 @@ import { GetAllPuzzlesProvider } from './providers/getAll-puzzle.provider'; providers: [PuzzlesService, CreatePuzzleProvider, GetAllPuzzlesProvider], exports: [TypeOrmModule, PuzzlesService], }) -export class PuzzlesModule {} \ No newline at end of file +export class PuzzlesModule {} diff --git a/backend/src/users/dtos/editUserDto.dto.ts b/backend/src/users/dtos/editUserDto.dto.ts index 2e3247d..c04f82b 100644 --- a/backend/src/users/dtos/editUserDto.dto.ts +++ b/backend/src/users/dtos/editUserDto.dto.ts @@ -3,4 +3,4 @@ import { CreateUserDto } from './createUserDto'; export class EditUserDto extends PartialType( OmitType(CreateUserDto, ['email', 'password'] as const), -) { } +) {} diff --git a/backend/src/users/providers/update-user.service.ts b/backend/src/users/providers/update-user.service.ts index 5b3b506..031349e 100644 --- a/backend/src/users/providers/update-user.service.ts +++ b/backend/src/users/providers/update-user.service.ts @@ -13,7 +13,7 @@ export class UpdateUserService { constructor( @InjectRepository(User) private readonly userRepository: Repository, - ) { } + ) {} async editUser(id: string, editUserDto: EditUserDto): Promise { const user = await this.userRepository.findOne({ where: { id } }); diff --git a/backend/src/users/user.entity.ts b/backend/src/users/user.entity.ts index e18ff24..7e4db7e 100644 --- a/backend/src/users/user.entity.ts +++ b/backend/src/users/user.entity.ts @@ -158,4 +158,39 @@ export class User { @OneToOne(() => Streak, (streak) => streak.user) streak: Streak; -} \ No newline at end of file + + /** + * Returns the total XP required to reach the next level + */ + getXpNeededForNextLevel(): number { + const thresholds = [1000, 2500, 5000, 10000]; + if (this.level < 5) { + return thresholds[this.level - 1]; + } + // Level 5+: 10000 + (level - 4) * 5000 + return 10000 + (this.level - 4) * 5000; + } + + /** + * Returns the progress percentage to the next level + */ + getXpProgressPercentage(): number { + const currentLevelXp = + this.level === 1 ? 0 : this.getXpNeededForLevel(this.level); + const nextLevelXp = this.getXpNeededForNextLevel(); + + if (nextLevelXp === currentLevelXp) return 100; + + const progress = + ((this.xp - currentLevelXp) / (nextLevelXp - currentLevelXp)) * 100; + return Math.min(Math.max(progress, 0), 100); + } + + private getXpNeededForLevel(level: number): number { + const thresholds = [0, 1000, 2500, 5000, 10000]; + if (level <= 5) { + return thresholds[level - 1]; + } + return 10000 + (level - 5) * 5000; + } +} diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts index acd0518..7ebdbd3 100644 --- a/backend/src/users/users.module.ts +++ b/backend/src/users/users.module.ts @@ -37,4 +37,4 @@ import { DailyQuest } from '../quests/entities/daily-quest.entity'; ], exports: [UsersService, TypeOrmModule], }) -export class UsersModule {} \ No newline at end of file +export class UsersModule {}