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
12 changes: 6 additions & 6 deletions backend/src/progress/entities/progress.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,26 +20,26 @@ export class UserProgress {
@PrimaryGeneratedColumn()
id: number;

@Column()
userId: number;
@Column('uuid')
userId: string;

@ManyToOne(() => User, (user) => user.progressRecords, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'userId' })
user: User;

@Column()
puzzleId: number;
@Column('uuid')
puzzleId: string;

@ManyToOne(() => Puzzle, (puzzle) => puzzle.progressRecords, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'puzzleId' })
puzzle: Puzzle;

@Column()
categoryId: number;
@Column('uuid')
categoryId: string;

@ManyToOne(() => Category, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'categoryId' })
Expand Down
13 changes: 11 additions & 2 deletions backend/src/progress/progress.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
118 changes: 101 additions & 17 deletions backend/src/progress/providers/progress-calculation.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,6 +34,12 @@ export class ProgressCalculationProvider {
private readonly puzzleRepository: Repository<Puzzle>,
@InjectRepository(UserProgress)
private readonly userProgressRepository: Repository<UserProgress>,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectRepository(Streak)
private readonly streakRepository: Repository<Streak>,
@InjectRepository(DailyQuest)
private readonly dailyQuestRepository: Repository<DailyQuest>,
) {}

/**
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion backend/src/puzzles/controllers/puzzles.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,4 @@ export class PuzzlesController {
getById(@Param('id') id: string) {
return this.puzzlesService.getPuzzleById(id);
}
}
}
8 changes: 4 additions & 4 deletions backend/src/puzzles/enums/puzzle-difficulty.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, number> = {
[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];
}
2 changes: 1 addition & 1 deletion backend/src/puzzles/puzzles.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ import { GetAllPuzzlesProvider } from './providers/getAll-puzzle.provider';
providers: [PuzzlesService, CreatePuzzleProvider, GetAllPuzzlesProvider],
exports: [TypeOrmModule, PuzzlesService],
})
export class PuzzlesModule {}
export class PuzzlesModule {}
2 changes: 1 addition & 1 deletion backend/src/users/dtos/editUserDto.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ import { CreateUserDto } from './createUserDto';

export class EditUserDto extends PartialType(
OmitType(CreateUserDto, ['email', 'password'] as const),
) { }
) {}
2 changes: 1 addition & 1 deletion backend/src/users/providers/update-user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export class UpdateUserService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) { }
) {}

async editUser(id: string, editUserDto: EditUserDto): Promise<User> {
const user = await this.userRepository.findOne({ where: { id } });
Expand Down
37 changes: 36 additions & 1 deletion backend/src/users/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,4 +158,39 @@ export class User {

@OneToOne(() => Streak, (streak) => streak.user)
streak: Streak;
}

/**
* 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;
}
}
2 changes: 1 addition & 1 deletion backend/src/users/users.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,4 @@ import { DailyQuest } from '../quests/entities/daily-quest.entity';
],
exports: [UsersService, TypeOrmModule],
})
export class UsersModule {}
export class UsersModule {}