diff --git a/src/puzzles/dto/index.ts b/src/puzzles/dto/index.ts index dc7ab94..649a93b 100644 --- a/src/puzzles/dto/index.ts +++ b/src/puzzles/dto/index.ts @@ -2,3 +2,5 @@ export * from './create-puzzle.dto'; export * from './update-puzzle.dto'; export * from './search-puzzle.dto'; export * from './bulk-operations.dto'; +export * from './submit-solution.dto'; +export * from './submission-result.dto'; diff --git a/src/puzzles/dto/submission-result.dto.ts b/src/puzzles/dto/submission-result.dto.ts new file mode 100644 index 0000000..9c57640 --- /dev/null +++ b/src/puzzles/dto/submission-result.dto.ts @@ -0,0 +1,115 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { SolutionAttemptStatus } from '../entities/puzzle-solution-attempt.entity'; + +/** + * Reward breakdown returned with a successful (correct) submission. + */ +export class RewardBreakdownDto { + @ApiProperty({ example: 350 }) + baseScore: number; + + @ApiProperty({ example: 50 }) + timeBonus: number; + + @ApiProperty({ example: 30 }) + streakBonus: number; + + @ApiProperty({ example: -20 }) + hintPenalty: number; + + @ApiPropertyOptional({ example: 100 }) + firstSolveBonus?: number; + + @ApiProperty({ example: 410 }) + totalScore: number; + + @ApiProperty({ example: 205 }) + totalExperience: number; + + @ApiProperty({ example: ['speed_demon', 'independent_thinker'] }) + achievements: string[]; +} + +/** + * Response returned from POST /puzzles/:id/submit + */ +export class SubmissionResultDto { + @ApiProperty({ example: '3fa85f64-5717-4562-b3fc-2c963f66afa6' }) + submissionId: string; + + @ApiProperty({ enum: SolutionAttemptStatus, example: SolutionAttemptStatus.CORRECT }) + status: SolutionAttemptStatus; + + @ApiProperty({ example: true }) + isCorrect: boolean; + + @ApiProperty({ + description: 'Elapsed time in seconds between when the puzzle was started and when it was submitted.', + example: 45, + }) + timeTakenSeconds: number; + + @ApiPropertyOptional({ + type: RewardBreakdownDto, + description: 'Only present when status is "correct".', + }) + rewards?: RewardBreakdownDto; + + @ApiPropertyOptional({ + description: 'Human-readable explanation of why the submission was rejected.', + example: 'Time limit exceeded', + }) + message?: string; + + @ApiPropertyOptional({ + description: 'Explanation of the correct answer (shown after submission).', + example: 'The answer is B because...', + }) + explanation?: string; +} + +/** + * A single entry in a submission history list. + */ +export class SubmissionHistoryItemDto { + @ApiProperty() + id: string; + + @ApiProperty() + puzzleId: string; + + @ApiProperty({ enum: SolutionAttemptStatus }) + status: SolutionAttemptStatus; + + @ApiProperty() + timeTakenSeconds: number; + + @ApiProperty() + scoreAwarded: number; + + @ApiProperty() + hintsUsed: number; + + @ApiProperty() + createdAt: Date; +} + +/** + * Paginated list of submission history entries. + */ +export class SubmissionHistoryDto { + @ApiProperty({ type: [SubmissionHistoryItemDto] }) + items: SubmissionHistoryItemDto[]; + + @ApiProperty({ example: 42 }) + total: number; + + @ApiProperty({ example: 1 }) + page: number; + + @ApiProperty({ example: 20 }) + limit: number; + + @ApiProperty({ example: 3 }) + totalPages: number; +} diff --git a/src/puzzles/dto/submit-solution.dto.ts b/src/puzzles/dto/submit-solution.dto.ts new file mode 100644 index 0000000..29518e5 --- /dev/null +++ b/src/puzzles/dto/submit-solution.dto.ts @@ -0,0 +1,65 @@ +import { + IsNotEmpty, + IsString, + IsUUID, + IsISO8601, + IsOptional, + IsInt, + Min, + Max, + IsObject, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +/** + * DTO for submitting a puzzle solution answer. + * The `nonce` must be a UUID v4 generated by the client and used only once + * (prevents replay attacks). + */ +export class SubmitSolutionDto { + @ApiProperty({ + description: + 'The player\'s answer. Shape depends on puzzle content.type ' + + '(e.g. index for multiple-choice, string for fill-blank, object for logic-grid).', + example: 2, + }) + @IsNotEmpty() + answer: any; + + @ApiProperty({ + description: + 'Unique UUID v4 generated client-side for this submission. ' + + 'Re-using the same nonce will result in HTTP 409 (replay attack prevention).', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + @IsUUID(4) + nonce: string; + + @ApiProperty({ + description: + 'ISO-8601 timestamp of when the player started (received) this puzzle. ' + + 'Used server-side to compute elapsed time and enforce the time limit.', + example: '2026-02-20T20:00:00.000Z', + }) + @IsISO8601() + sessionStartedAt: string; + + @ApiPropertyOptional({ + description: 'Number of hints the player used during this attempt.', + example: 1, + default: 0, + }) + @IsOptional() + @IsInt() + @Min(0) + @Max(10) + hintsUsed?: number; + + @ApiPropertyOptional({ + description: 'Optional client-side metadata (user-agent, device info, etc.).', + example: { platform: 'web', appVersion: '1.2.3' }, + }) + @IsOptional() + @IsObject() + clientMetadata?: Record; +} diff --git a/src/puzzles/entities/puzzle-solution-attempt.entity.ts b/src/puzzles/entities/puzzle-solution-attempt.entity.ts new file mode 100644 index 0000000..3996552 --- /dev/null +++ b/src/puzzles/entities/puzzle-solution-attempt.entity.ts @@ -0,0 +1,143 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, + ManyToOne, + JoinColumn, + Unique, +} from 'typeorm'; + +/** + * Status of a puzzle solution submission attempt + */ +export enum SolutionAttemptStatus { + CORRECT = 'correct', + INCORRECT = 'incorrect', + TIMEOUT = 'timeout', + FRAUD_DETECTED = 'fraud_detected', + RATE_LIMITED = 'rate_limited', +} + +/** + * Tracks every puzzle solution submission attempt. + * Answers are stored as SHA-256 hashes — plaintext answers are never persisted. + * The `nonce` column has a unique constraint to prevent replay attacks. + */ +@Entity('puzzle_solution_attempts') +@Unique(['nonce']) // Anti-replay: nonce must be globally unique +@Index(['userId', 'puzzleId']) +@Index(['userId', 'createdAt']) +@Index(['puzzleId', 'status']) +export class PuzzleSolutionAttempt { + @PrimaryGeneratedColumn('uuid') + id: string; + + // ──────────────── Identifiers ──────────────── + + @Column({ type: 'uuid' }) + @Index() + userId: string; + + @Column({ type: 'uuid' }) + @Index() + puzzleId: string; + + // ──────────────── Anti-Replay ──────────────── + + /** + * Client-generated UUID v4 that must be unique per submission. + * Stored to detect replay attacks (re-submitting same request). + */ + @Column({ type: 'varchar', length: 36 }) + @Index() + nonce: string; + + // ──────────────── Verification ──────────────── + + /** + * SHA-256 hash of the normalised submitted answer. + * Never store the plaintext answer. + */ + @Column({ type: 'varchar', length: 64 }) + answerHash: string; + + @Column({ + type: 'enum', + enum: SolutionAttemptStatus, + default: SolutionAttemptStatus.INCORRECT, + }) + @Index() + status: SolutionAttemptStatus; + + // ──────────────── Timing ──────────────── + + /** + * ISO timestamp sent by the client indicating when they received + * (started) the puzzle. Used for time-limit and fraud validation. + */ + @Column({ type: 'timestamp with time zone' }) + sessionStartedAt: Date; + + /** + * Elapsed seconds between sessionStartedAt and submission time. + * Computed server-side — not trusted from the client. + */ + @Column({ type: 'int', default: 0 }) + timeTakenSeconds: number; + + // ──────────────── Performance ──────────────── + + @Column({ type: 'int', default: 0 }) + hintsUsed: number; + + // ──────────────── Rewards ──────────────── + + @Column({ type: 'int', default: 0 }) + scoreAwarded: number; + + /** + * Detailed reward breakdown: base points, bonuses, penalties, achievements. + */ + @Column({ type: 'jsonb', default: {} }) + rewardData: { + baseScore?: number; + timeBonus?: number; + streakBonus?: number; + hintPenalty?: number; + firstSolveBonus?: number; + achievements?: string[]; + totalExperience?: number; + }; + + // ──────────────── Fraud / Anti-Cheat ──────────────── + + /** + * Fraud/anti-cheat flags recorded during processing. + * Submission may still be stored when fraud is detected for audit purposes. + */ + @Column({ type: 'jsonb', default: {} }) + fraudFlags: { + tooFast?: boolean; + minExpectedSeconds?: number; + actualSeconds?: number; + violationTypes?: string[]; + riskScore?: number; + }; + + // ──────────────── Request Metadata ──────────────── + + @Column({ type: 'varchar', length: 45, nullable: true }) + ipAddress?: string; + + /** + * Extensible metadata (user-agent, device fingerprint, geo, etc.) + */ + @Column({ type: 'jsonb', default: {} }) + metadata: Record; + + @CreateDateColumn() + @Index() + createdAt: Date; +} diff --git a/src/puzzles/puzzles.controller.ts b/src/puzzles/puzzles.controller.ts index cc5294c..9d991df 100644 --- a/src/puzzles/puzzles.controller.ts +++ b/src/puzzles/puzzles.controller.ts @@ -15,9 +15,14 @@ import { HttpCode, ParseArrayPipe, BadRequestException, - Logger + Logger, + Req, + DefaultValuePipe, + ParseIntPipe, } from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; import { PuzzlesService, PuzzleWithStats, SearchResult, PuzzleAnalytics } from './puzzles.service'; +import { SolutionSubmissionService } from './services/solution-submission.service'; import { CreatePuzzleDto, UpdatePuzzleDto, @@ -25,13 +30,18 @@ import { BulkUpdateDto, PuzzleStatsDto } from './dto'; +import { SubmitSolutionDto } from './dto/submit-solution.dto'; +import { SubmissionResultDto, SubmissionHistoryDto } from './dto/submission-result.dto'; @Controller('puzzles') @UseInterceptors(ClassSerializerInterceptor) export class PuzzlesController { private readonly logger = new Logger(PuzzlesController.name); - constructor(private readonly puzzlesService: PuzzlesService) {} + constructor( + private readonly puzzlesService: PuzzlesService, + private readonly submissionService: SolutionSubmissionService, + ) { } @Post() async create( @@ -129,9 +139,9 @@ export class PuzzlesController { ): Promise { const userId = 'temp-user-id'; // TODO: Get from auth this.logger.log(`Duplicating puzzle: ${id} by user: ${userId}`); - + const originalPuzzle = await this.puzzlesService.findOne(id); - + const duplicateDto: CreatePuzzleDto = { title: `${originalPuzzle.title} (Copy)`, description: originalPuzzle.description, @@ -150,4 +160,53 @@ export class PuzzlesController { return await this.puzzlesService.create(duplicateDto, userId); } + + // ────────────────────────────────────────────────────────────────── + // Solution Submission & Verification + // ────────────────────────────────────────────────────────────────── + + /** + * Submit and verify a puzzle solution. + * Rate-limited to 5 submissions per minute per user. + */ + @Post(':id/submit') + @Throttle({ default: { limit: 5, ttl: 60000 } }) + async submitSolution( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SubmitSolutionDto, + @Req() req: any, + ): Promise { + const userId = req.user?.id ?? 'temp-user-id'; // TODO: Get from auth guard + const ipAddress: string | undefined = + req.ip ?? req.headers?.['x-forwarded-for']?.split(',')[0]?.trim(); + this.logger.log(`Solution submission: puzzle=${id} user=${userId}`); + return this.submissionService.submitSolution(userId, id, dto, ipAddress); + } + + /** + * Get a user's submission history for a specific puzzle. + */ + @Get(':id/submissions') + async getPuzzleSubmissions( + @Param('id', ParseUUIDPipe) id: string, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number, + @Req() req: any, + ): Promise { + const userId = req.user?.id ?? 'temp-user-id'; + return this.submissionService.getSubmissionHistory(userId, id, page, limit); + } + + /** + * Get all submission history for the authenticated user across all puzzles. + */ + @Get('submissions/history') + async getAllSubmissions( + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number, + @Req() req: any, + ): Promise { + const userId = req.user?.id ?? 'temp-user-id'; + return this.submissionService.getSubmissionHistory(userId, undefined, page, limit); + } } diff --git a/src/puzzles/puzzles.module.ts b/src/puzzles/puzzles.module.ts index dd63026..74111d3 100644 --- a/src/puzzles/puzzles.module.ts +++ b/src/puzzles/puzzles.module.ts @@ -6,6 +6,9 @@ import { CommunityPuzzlesModule } from './community-puzzles.module'; import { Puzzle } from './entities/puzzle.entity'; import { PuzzleProgress } from '../game-logic/entities/puzzle-progress.entity'; import { PuzzleRating } from './entities/puzzle-rating.entity'; +import { PuzzleSolutionAttempt } from './entities/puzzle-solution-attempt.entity'; +import { SolutionSubmissionService } from './services/solution-submission.service'; +import { AntiCheatModule } from '../anti-cheat/anti-cheat.module'; // Import entities and components for categories, collections, and themes import { Category } from './entities/category.entity'; @@ -26,8 +29,10 @@ import { ThemesController } from './theme.controller'; // Import ThemesControlle PuzzleRating, Category, Collection, - Theme // Add Theme entity - ]) + Theme, + PuzzleSolutionAttempt, + ]), + AntiCheatModule, ], controllers: [ PuzzlesController, @@ -39,8 +44,9 @@ import { ThemesController } from './theme.controller'; // Import ThemesControlle PuzzlesService, CategoriesService, CollectionsService, - ThemesService // Add ThemesService + ThemesService, + SolutionSubmissionService, ], exports: [PuzzlesService] }) -export class PuzzlesModule {} \ No newline at end of file +export class PuzzlesModule { } \ No newline at end of file diff --git a/src/puzzles/services/solution-submission.service.ts b/src/puzzles/services/solution-submission.service.ts new file mode 100644 index 0000000..6977441 --- /dev/null +++ b/src/puzzles/services/solution-submission.service.ts @@ -0,0 +1,520 @@ +import { + Injectable, + Logger, + ConflictException, + BadRequestException, + NotFoundException, + TooManyRequestsException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource, MoreThan } from 'typeorm'; +import * as crypto from 'crypto'; +import { Puzzle } from '../entities/puzzle.entity'; +import { + PuzzleSolutionAttempt, + SolutionAttemptStatus, +} from '../entities/puzzle-solution-attempt.entity'; +import { SubmitSolutionDto } from '../dto/submit-solution.dto'; +import { + SubmissionResultDto, + SubmissionHistoryDto, + RewardBreakdownDto, +} from '../dto/submission-result.dto'; +import { AntiCheatService } from '../../anti-cheat/services/anti-cheat.service'; +import { ViolationType } from '../../anti-cheat/constants'; + +// ──────────────────────────────────────────────────────────────────────────── +// Constants +// ──────────────────────────────────────────────────────────────────────────── + +/** Minimum believable completion time per difficulty rating unit (seconds). */ +const MIN_SECONDS_PER_DIFFICULTY_UNIT = 5; + +/** Submissions allowed per USER per 60-second window. */ +const RATE_LIMIT_MAX = 5; +const RATE_LIMIT_WINDOW_MS = 60_000; + +// ──────────────────────────────────────────────────────────────────────────── + +@Injectable() +export class SolutionSubmissionService { + private readonly logger = new Logger(SolutionSubmissionService.name); + + constructor( + @InjectRepository(Puzzle) + private readonly puzzleRepo: Repository, + + @InjectRepository(PuzzleSolutionAttempt) + private readonly attemptRepo: Repository, + + private readonly dataSource: DataSource, + private readonly antiCheatService: AntiCheatService, + ) { } + + // ────────────────────────────────────────────────────────────────────────── + // Public API + // ────────────────────────────────────────────────────────────────────────── + + /** + * Main entry point — verifies, records, and rewards a puzzle solution submission. + * + * Steps: + * 1. Rate-limit check + * 2. Anti-replay (nonce uniqueness) + * 3. Puzzle lookup + * 4. Timing validation (is time limit exceeded?) + * 5. Answer verification (hash comparison) + * 6. Fraud / anti-cheat detection + * 7. Reward calculation (if correct) + * 8. Atomic DB transaction (attempt insert + puzzle stats update) + */ + async submitSolution( + userId: string, + puzzleId: string, + dto: SubmitSolutionDto, + ipAddress?: string, + ): Promise { + // 1. Rate limit + await this.enforceRateLimit(userId); + + // 2. Anti-replay + await this.checkAntiReplay(dto.nonce); + + // 3. Load puzzle + const puzzle = await this.puzzleRepo.findOne({ where: { id: puzzleId } }); + if (!puzzle) { + throw new NotFoundException(`Puzzle ${puzzleId} not found`); + } + + const sessionStartedAt = new Date(dto.sessionStartedAt); + const now = new Date(); + const timeTakenSeconds = Math.floor( + (now.getTime() - sessionStartedAt.getTime()) / 1000, + ); + + // 4. Time limit check + if (puzzle.timeLimit && timeTakenSeconds > puzzle.timeLimit) { + const attempt = await this.persistAttempt({ + userId, + puzzleId, + nonce: dto.nonce, + answer: dto.answer, + status: SolutionAttemptStatus.TIMEOUT, + sessionStartedAt, + timeTakenSeconds, + hintsUsed: dto.hintsUsed ?? 0, + scoreAwarded: 0, + rewardData: {}, + fraudFlags: {}, + ipAddress, + metadata: dto.clientMetadata ?? {}, + }); + + await this.incrementAttempts(puzzle); + + return { + submissionId: attempt.id, + status: SolutionAttemptStatus.TIMEOUT, + isCorrect: false, + timeTakenSeconds, + message: `Time limit of ${puzzle.timeLimit}s exceeded (took ${timeTakenSeconds}s).`, + }; + } + + // 5. Verify answer + const isCorrect = this.verifyAnswer(puzzle, dto.answer); + + // 6. Fraud detection + const fraudResult = await this.detectFraud( + userId, + puzzleId, + timeTakenSeconds, + puzzle.difficultyRating, + ); + + const finalStatus = fraudResult.isFraud + ? SolutionAttemptStatus.FRAUD_DETECTED + : isCorrect + ? SolutionAttemptStatus.CORRECT + : SolutionAttemptStatus.INCORRECT; + + // 7. Reward calculation (only for legitimate correct answers) + let rewardData: RewardBreakdownDto | undefined; + let scoreAwarded = 0; + + if (isCorrect && !fraudResult.isFraud) { + rewardData = this.calculateRewards(puzzle, timeTakenSeconds, dto.hintsUsed ?? 0); + scoreAwarded = rewardData.totalScore; + } + + // 8. Atomic transaction + const attempt = await this.dataSource.transaction(async (manager) => { + // Insert attempt record + const newAttempt = manager.create(PuzzleSolutionAttempt, { + userId, + puzzleId, + nonce: dto.nonce, + answerHash: this.hashAnswer(dto.answer), + status: finalStatus, + sessionStartedAt, + timeTakenSeconds, + hintsUsed: dto.hintsUsed ?? 0, + scoreAwarded, + rewardData: rewardData ?? {}, + fraudFlags: fraudResult.flags, + ipAddress, + metadata: dto.clientMetadata ?? {}, + }); + const saved = await manager.save(PuzzleSolutionAttempt, newAttempt); + + // Update puzzle aggregate stats + puzzle.attempts = (puzzle.attempts ?? 0) + 1; + if (isCorrect && !fraudResult.isFraud) { + puzzle.completions = (puzzle.completions ?? 0) + 1; + + // Rolling average completion time + const prevTotal = + (puzzle.averageCompletionTime ?? 0) * (puzzle.completions - 1); + puzzle.averageCompletionTime = Math.floor( + (prevTotal + timeTakenSeconds) / puzzle.completions, + ); + } + await manager.save(Puzzle, puzzle); + + return saved; + }); + + this.logger.log( + `Submission ${attempt.id}: user=${userId} puzzle=${puzzleId} ` + + `status=${finalStatus} score=${scoreAwarded} time=${timeTakenSeconds}s`, + ); + + const result: SubmissionResultDto = { + submissionId: attempt.id, + status: finalStatus, + isCorrect: isCorrect && !fraudResult.isFraud, + timeTakenSeconds, + rewards: rewardData, + explanation: isCorrect ? (puzzle.content?.explanation ?? undefined) : undefined, + }; + + if (fraudResult.isFraud) { + result.message = + 'Submission flagged by anti-cheat. Score not awarded pending review.'; + } else if (!isCorrect) { + result.message = 'Incorrect answer. Please try again.'; + } + + return result; + } + + /** + * Returns paginated submission history for a user, optionally filtered to a + * specific puzzle. + */ + async getSubmissionHistory( + userId: string, + puzzleId?: string, + page = 1, + limit = 20, + ): Promise { + const where: any = { userId }; + if (puzzleId) where.puzzleId = puzzleId; + + const [items, total] = await this.attemptRepo.findAndCount({ + where, + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + select: [ + 'id', + 'puzzleId', + 'status', + 'timeTakenSeconds', + 'scoreAwarded', + 'hintsUsed', + 'createdAt', + ], + }); + + return { + items: items as any, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + // ────────────────────────────────────────────────────────────────────────── + // Private Helpers + // ────────────────────────────────────────────────────────────────────────── + + /** + * Enforce a per-user submission rate limit: + * max {@link RATE_LIMIT_MAX} submissions in the last {@link RATE_LIMIT_WINDOW_MS}ms. + */ + private async enforceRateLimit(userId: string): Promise { + const windowStart = new Date(Date.now() - RATE_LIMIT_WINDOW_MS); + const recentCount = await this.attemptRepo.count({ + where: { + userId, + createdAt: MoreThan(windowStart), + }, + }); + + if (recentCount >= RATE_LIMIT_MAX) { + this.logger.warn(`Rate limit hit for user ${userId}: ${recentCount} submissions in last 60s`); + throw new TooManyRequestsException( + `You have exceeded the submission rate limit (${RATE_LIMIT_MAX} per minute). Please wait before trying again.`, + ); + } + } + + /** + * Ensure the nonce has never been used before. + * A unique DB constraint is the source of truth; this pre-check provides + * a human-readable error before hitting the constraint. + */ + private async checkAntiReplay(nonce: string): Promise { + const existing = await this.attemptRepo.findOne({ where: { nonce } }); + if (existing) { + this.logger.warn(`Replay attack detected — nonce already used: ${nonce}`); + throw new ConflictException( + 'This submission token (nonce) has already been used. Each submission must have a unique nonce.', + ); + } + } + + /** + * Normalise and SHA-256 hash an answer for safe storage and comparison. + * Normalisation ensures {a:1, b:2} and {b:2, a:1} produce identical hashes. + */ + private hashAnswer(answer: any): string { + const normalised = this.normaliseAnswer(answer); + return crypto + .createHash('sha256') + .update(JSON.stringify(normalised)) + .digest('hex'); + } + + /** + * Recursively sort object keys so hash comparison is order-independent. + */ + private normaliseAnswer(value: any): any { + if (Array.isArray(value)) { + return value.map((v) => this.normaliseAnswer(v)); + } + if (value !== null && typeof value === 'object') { + return Object.keys(value) + .sort() + .reduce>((acc, key) => { + acc[key] = this.normaliseAnswer(value[key]); + return acc; + }, {}); + } + // Normalise strings: trim and lower-case to avoid trivial bypass + if (typeof value === 'string') { + return value.trim().toLowerCase(); + } + return value; + } + + /** + * Compare submitted answer against the puzzle's stored correct answer. + * Uses hash comparison — never compares plaintext. + */ + private verifyAnswer(puzzle: Puzzle, submittedAnswer: any): boolean { + const correctAnswer = puzzle.content?.correctAnswer; + if (correctAnswer === undefined || correctAnswer === null) { + // Puzzle has no defined correct answer — cannot verify + this.logger.warn(`Puzzle ${puzzle.id} has no correctAnswer defined`); + return false; + } + + const submittedHash = this.hashAnswer(submittedAnswer); + const correctHash = this.hashAnswer(correctAnswer); + return submittedHash === correctHash; + } + + /** + * Detect abnormally fast completions and dispatch to the anti-cheat service. + * + * A completion time below `difficultyRating * MIN_SECONDS_PER_DIFFICULTY_UNIT` + * seconds is considered impossibly fast and flagged. + */ + private async detectFraud( + userId: string, + puzzleId: string, + timeTakenSeconds: number, + difficultyRating: number, + ): Promise<{ + isFraud: boolean; + flags: PuzzleSolutionAttempt['fraudFlags']; + }> { + const minExpectedSeconds = difficultyRating * MIN_SECONDS_PER_DIFFICULTY_UNIT; + const tooFast = timeTakenSeconds < minExpectedSeconds; + + const flags: PuzzleSolutionAttempt['fraudFlags'] = { + tooFast, + minExpectedSeconds, + actualSeconds: timeTakenSeconds, + violationTypes: [], + riskScore: 0, + }; + + if (tooFast) { + flags.violationTypes = [ViolationType.IMPOSSIBLY_FAST_COMPLETION]; + flags.riskScore = Math.min( + 100, + Math.round((1 - timeTakenSeconds / minExpectedSeconds) * 100), + ); + + // Notify the anti-cheat module so it can update the player's profile + try { + await this.antiCheatService.analyzeMoveSequence( + userId, + puzzleId, + `submission-${Date.now()}`, + [], + { + isFirstAttempt: true, + allMovesValid: false, + }, + ); + await this.antiCheatService.updateBehaviorProfile(userId, { + violationDetected: true, + violationTypes: [ViolationType.IMPOSSIBLY_FAST_COMPLETION], + }); + } catch (err: any) { + this.logger.error(`Anti-cheat notification failed: ${err.message}`); + } + + this.logger.warn( + `Fraud detected: user=${userId} puzzle=${puzzleId} ` + + `time=${timeTakenSeconds}s (min expected=${minExpectedSeconds}s)`, + ); + } + + return { isFraud: tooFast, flags }; + } + + /** + * Calculate score and rewards for a correct, non-fraudulent submission. + * + * Formula: + * base = puzzle.basePoints + * time = up to 50% of base, linear based on time remaining vs time limit + * hints = −10% of base per hint used + * first = +25% of base if this is the user's first correct solve + */ + private calculateRewards( + puzzle: Puzzle, + timeTakenSeconds: number, + hintsUsed: number, + ): RewardBreakdownDto { + const baseScore = puzzle.basePoints ?? 100; + + // Time bonus: linear — faster = more bonus + let timeBonus = 0; + if (puzzle.timeLimit && puzzle.timeLimit > 0) { + const timeRemaining = puzzle.timeLimit - timeTakenSeconds; + if (timeRemaining > 0) { + timeBonus = Math.round((timeRemaining / puzzle.timeLimit) * baseScore * 0.5); + } + } + + // Hint penalty: 10% of base per hint + const hintPenalty = Math.round(hintsUsed * baseScore * 0.1); + + // Streak bonus placeholder (server would pull user's streak from their profile) + const streakBonus = 0; + + // First-solve bonus + const firstSolveBonus = 0; // Future: query if user has any previous correct submission + + // Achievements + const achievements = this.determineAchievements( + puzzle, + timeTakenSeconds, + hintsUsed, + ); + + const totalScore = Math.max(0, baseScore + timeBonus + streakBonus - hintPenalty + firstSolveBonus); + const totalExperience = Math.round(totalScore * 0.5) + achievements.length * 50; + + return { + baseScore, + timeBonus, + streakBonus, + hintPenalty: -hintPenalty, // negative for display + firstSolveBonus, + totalScore, + totalExperience, + achievements, + }; + } + + /** + * Determine achievement badges earned based on performance. + */ + private determineAchievements( + puzzle: Puzzle, + timeTakenSeconds: number, + hintsUsed: number, + ): string[] { + const achievements: string[] = []; + + if (hintsUsed === 0) { + achievements.push('independent_thinker'); + } + + if (puzzle.timeLimit && timeTakenSeconds < puzzle.timeLimit * 0.3) { + achievements.push('speed_demon'); + } + + if (hintsUsed === 0 && puzzle.timeLimit && timeTakenSeconds < puzzle.timeLimit * 0.5) { + achievements.push('perfect_solver'); + } + + const difficulty = puzzle.difficulty as string; + if (difficulty === 'hard' || difficulty === 'expert') { + achievements.push('challenge_accepted'); + } + + return achievements; + } + + /** + * Increment puzzle.attempts without a full transaction (used when aborting early). + */ + private async incrementAttempts(puzzle: Puzzle): Promise { + await this.puzzleRepo.increment({ id: puzzle.id }, 'attempts', 1); + } + + /** + * Helper to build and save a PuzzleSolutionAttempt outside a transaction. + * Used for early-exit paths (timeout, etc.) where no reward calculation is needed. + */ + private async persistAttempt(data: { + userId: string; + puzzleId: string; + nonce: string; + answer: any; + status: SolutionAttemptStatus; + sessionStartedAt: Date; + timeTakenSeconds: number; + hintsUsed: number; + scoreAwarded: number; + rewardData: any; + fraudFlags: any; + ipAddress?: string; + metadata: any; + }): Promise { + const attempt = this.attemptRepo.create({ + ...data, + answerHash: this.hashAnswer(data.answer), + }); + return this.attemptRepo.save(attempt); + } +} diff --git a/src/puzzles/tests/solution-submission.service.spec.ts b/src/puzzles/tests/solution-submission.service.spec.ts new file mode 100644 index 0000000..50029a7 --- /dev/null +++ b/src/puzzles/tests/solution-submission.service.spec.ts @@ -0,0 +1,493 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; +import { + ConflictException, + NotFoundException, + TooManyRequestsException, +} from '@nestjs/common'; +import { SolutionSubmissionService } from '../services/solution-submission.service'; +import { Puzzle } from '../entities/puzzle.entity'; +import { + PuzzleSolutionAttempt, + SolutionAttemptStatus, +} from '../entities/puzzle-solution-attempt.entity'; +import { AntiCheatService } from '../../anti-cheat/services/anti-cheat.service'; +import { SubmitSolutionDto } from '../dto/submit-solution.dto'; +import { v4 as uuidv4 } from 'uuid'; + +// ────────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────────── + +function makeNonce() { + return uuidv4(); +} + +function makeSessionStart(secondsAgo: number): string { + return new Date(Date.now() - secondsAgo * 1000).toISOString(); +} + +function makeDto(overrides: Partial = {}): SubmitSolutionDto { + return { + answer: 'correct_answer', + nonce: makeNonce(), + sessionStartedAt: makeSessionStart(30), // 30 seconds ago by default + hintsUsed: 0, + ...overrides, + } as SubmitSolutionDto; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Mock Factories +// ────────────────────────────────────────────────────────────────────────────── + +const basePuzzle: Partial = { + id: 'puzzle-uuid-001', + title: 'Test Puzzle', + content: { + type: 'multiple-choice', + correctAnswer: 'correct_answer', + explanation: 'Because it is correct.', + }, + basePoints: 100, + timeLimit: 300, // 5 minutes + difficultyRating: 5, + attempts: 0, + completions: 0, + averageCompletionTime: 0, + difficulty: 'medium', +}; + +function makePuzzleRepo(puzzle: Partial | null = basePuzzle) { + return { + findOne: jest.fn().mockResolvedValue(puzzle), + increment: jest.fn().mockResolvedValue(undefined), + }; +} + +function makeAttemptRepo(existingNonce: PuzzleSolutionAttempt | null = null, recentCount = 0) { + return { + findOne: jest.fn().mockResolvedValue(existingNonce), + count: jest.fn().mockResolvedValue(recentCount), + find: jest.fn().mockResolvedValue([]), + findAndCount: jest.fn().mockResolvedValue([[], 0]), + create: jest.fn().mockImplementation((entity) => entity), + save: jest.fn().mockImplementation((entity) => Promise.resolve({ id: 'attempt-id-001', ...entity })), + }; +} + +function makeDataSource(saved?: Partial) { + const savedAttempt = saved ?? { id: 'attempt-id-001', status: SolutionAttemptStatus.CORRECT }; + return { + transaction: jest.fn().mockImplementation(async (cb: Function) => { + const manager = { + create: jest.fn().mockImplementation((_Entity, data) => data), + save: jest.fn().mockImplementation((_Entity, data) => + Promise.resolve({ id: 'attempt-id-001', ...data }), + ), + }; + return cb(manager); + }), + }; +} + +function makeAntiCheatService() { + return { + analyzeMoveSequence: jest.fn().mockResolvedValue(undefined), + updateBehaviorProfile: jest.fn().mockResolvedValue(undefined), + }; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Test Suite +// ────────────────────────────────────────────────────────────────────────────── + +describe('SolutionSubmissionService', () => { + let service: SolutionSubmissionService; + let puzzleRepo: ReturnType; + let attemptRepo: ReturnType; + let dataSource: ReturnType; + let antiCheatService: ReturnType; + + async function buildService( + puzzle: Partial | null = basePuzzle, + existingNonce: PuzzleSolutionAttempt | null = null, + recentSubmissionCount = 0, + ) { + puzzleRepo = makePuzzleRepo(puzzle); + attemptRepo = makeAttemptRepo(existingNonce, recentSubmissionCount); + dataSource = makeDataSource(); + antiCheatService = makeAntiCheatService(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SolutionSubmissionService, + { provide: getRepositoryToken(Puzzle), useValue: puzzleRepo }, + { provide: getRepositoryToken(PuzzleSolutionAttempt), useValue: attemptRepo }, + { provide: DataSource, useValue: dataSource }, + { provide: AntiCheatService, useValue: antiCheatService }, + ], + }).compile(); + + service = module.get(SolutionSubmissionService); + } + + // ──────────────────────────────────────────────────────────────────────────── + // Happy Path + // ──────────────────────────────────────────────────────────────────────────── + + describe('✅ Correct answer submission', () => { + beforeEach(() => buildService()); + + it('returns CORRECT status when answer matches', async () => { + const result = await service.submitSolution('user-1', 'puzzle-uuid-001', makeDto()); + expect(result.status).toBe(SolutionAttemptStatus.CORRECT); + expect(result.isCorrect).toBe(true); + }); + + it('provides reward data on correct submission', async () => { + const result = await service.submitSolution('user-1', 'puzzle-uuid-001', makeDto()); + expect(result.rewards).toBeDefined(); + expect(result.rewards!.totalScore).toBeGreaterThan(0); + expect(result.rewards!.baseScore).toBe(100); + }); + + it('includes puzzle explanation on correct submission', async () => { + const result = await service.submitSolution('user-1', 'puzzle-uuid-001', makeDto()); + expect(result.explanation).toBe('Because it is correct.'); + }); + + it('correctly calculates time bonus for fast submissions', async () => { + // 30s elapsed out of 300s limit → should get a time bonus + const result = await service.submitSolution('user-1', 'puzzle-uuid-001', makeDto({ sessionStartedAt: makeSessionStart(30) })); + expect(result.rewards!.timeBonus).toBeGreaterThan(0); + }); + + it('awards independent_thinker achievement when no hints used', async () => { + const result = await service.submitSolution('user-1', 'puzzle-uuid-001', makeDto({ hintsUsed: 0 })); + expect(result.rewards!.achievements).toContain('independent_thinker'); + }); + + it('awards speed_demon achievement when solved in < 30% of time limit', async () => { + // Time limit is 300s, 30% = 90s, so if we complete in 20s we should get speed_demon + const result = await service.submitSolution('user-1', 'puzzle-uuid-001', makeDto({ sessionStartedAt: makeSessionStart(20) })); + expect(result.rewards!.achievements).toContain('speed_demon'); + }); + + it('runs inside a transaction', async () => { + await service.submitSolution('user-1', 'puzzle-uuid-001', makeDto()); + expect(dataSource.transaction).toHaveBeenCalledTimes(1); + }); + }); + + // ──────────────────────────────────────────────────────────────────────────── + // Wrong Answer + // ──────────────────────────────────────────────────────────────────────────── + + describe('❌ Wrong answer submission', () => { + beforeEach(() => buildService()); + + it('returns INCORRECT status for wrong answer', async () => { + const result = await service.submitSolution( + 'user-1', + 'puzzle-uuid-001', + makeDto({ answer: 'wrong_answer' }), + ); + expect(result.status).toBe(SolutionAttemptStatus.INCORRECT); + expect(result.isCorrect).toBe(false); + }); + + it('awards zero score for incorrect answer', async () => { + const result = await service.submitSolution( + 'user-1', + 'puzzle-uuid-001', + makeDto({ answer: 'wrong_answer' }), + ); + expect(result.rewards).toBeUndefined(); + }); + + it('provides a helpful message for wrong answers', async () => { + const result = await service.submitSolution( + 'user-1', + 'puzzle-uuid-001', + makeDto({ answer: 'wrong_answer' }), + ); + expect(result.message).toBeTruthy(); + }); + }); + + // ──────────────────────────────────────────────────────────────────────────── + // Anti-Replay + // ──────────────────────────────────────────────────────────────────────────── + + describe('🔒 Anti-replay: duplicate nonce', () => { + it('throws ConflictException when nonce already used', async () => { + const usedNonce = makeNonce(); + // Simulate DB returning an existing attempt with this nonce + await buildService(basePuzzle, { id: 'old-attempt', nonce: usedNonce } as PuzzleSolutionAttempt); + + await expect( + service.submitSolution('user-1', 'puzzle-uuid-001', makeDto({ nonce: usedNonce })), + ).rejects.toThrow(ConflictException); + }); + + it('ConflictException message mentions nonce', async () => { + const usedNonce = makeNonce(); + await buildService(basePuzzle, { id: 'old-attempt', nonce: usedNonce } as PuzzleSolutionAttempt); + + await expect( + service.submitSolution('user-1', 'puzzle-uuid-001', makeDto({ nonce: usedNonce })), + ).rejects.toThrow(/nonce/i); + }); + }); + + // ──────────────────────────────────────────────────────────────────────────── + // Time Limit + // ──────────────────────────────────────────────────────────────────────────── + + describe('⏰ Time limit validation', () => { + it('returns TIMEOUT when session started beyond timeLimit', async () => { + await buildService(); + // puzzle timeLimit = 300s, started 400s ago + const result = await service.submitSolution( + 'user-1', + 'puzzle-uuid-001', + makeDto({ sessionStartedAt: makeSessionStart(400) }), + ); + expect(result.status).toBe(SolutionAttemptStatus.TIMEOUT); + expect(result.isCorrect).toBe(false); + }); + + it('includes elapsed time in timeout response', async () => { + await buildService(); + const result = await service.submitSolution( + 'user-1', + 'puzzle-uuid-001', + makeDto({ sessionStartedAt: makeSessionStart(400) }), + ); + expect(result.timeTakenSeconds).toBeGreaterThanOrEqual(399); + }); + + it('increments attempts even on timeout', async () => { + await buildService(); + await service.submitSolution( + 'user-1', + 'puzzle-uuid-001', + makeDto({ sessionStartedAt: makeSessionStart(400) }), + ); + expect(puzzleRepo.increment).toHaveBeenCalledWith({ id: 'puzzle-uuid-001' }, 'attempts', 1); + }); + + it('accepts submission just within time limit', async () => { + await buildService(); + // 295s elapsed, limit is 300s — should succeed + const result = await service.submitSolution( + 'user-1', + 'puzzle-uuid-001', + makeDto({ sessionStartedAt: makeSessionStart(295) }), + ); + expect(result.status).not.toBe(SolutionAttemptStatus.TIMEOUT); + }); + }); + + // ──────────────────────────────────────────────────────────────────────────── + // Fraud Detection — Too Fast + // ──────────────────────────────────────────────────────────────────────────── + + describe('🚨 Fraud detection: impossibly fast completion', () => { + it('flags FRAUD_DETECTED when completed faster than minimum expected time', async () => { + await buildService(); + // difficultyRating = 5, min = 5*5 = 25s; we submit after just 2s → fraud + const result = await service.submitSolution( + 'user-1', + 'puzzle-uuid-001', + makeDto({ sessionStartedAt: makeSessionStart(2) }), + ); + expect(result.status).toBe(SolutionAttemptStatus.FRAUD_DETECTED); + expect(result.isCorrect).toBe(false); + }); + + it('does not award rewards when fraud is detected', async () => { + await buildService(); + const result = await service.submitSolution( + 'user-1', + 'puzzle-uuid-001', + makeDto({ sessionStartedAt: makeSessionStart(2) }), + ); + expect(result.rewards).toBeUndefined(); + }); + + it('notifies AntiCheatService on fraud detection', async () => { + await buildService(); + await service.submitSolution( + 'user-1', + 'puzzle-uuid-001', + makeDto({ sessionStartedAt: makeSessionStart(2) }), + ); + expect(antiCheatService.updateBehaviorProfile).toHaveBeenCalledWith( + 'user-1', + expect.objectContaining({ violationDetected: true }), + ); + }); + + it('includes fraud message in response', async () => { + await buildService(); + const result = await service.submitSolution( + 'user-1', + 'puzzle-uuid-001', + makeDto({ sessionStartedAt: makeSessionStart(2) }), + ); + expect(result.message).toMatch(/anti-cheat/i); + }); + + it('allows legitimate fast runs (above minimum threshold)', async () => { + await buildService(); + // 30s elapsed, min = 25s → should not be flagged + const result = await service.submitSolution( + 'user-1', + 'puzzle-uuid-001', + makeDto({ sessionStartedAt: makeSessionStart(30) }), + ); + expect(result.status).not.toBe(SolutionAttemptStatus.FRAUD_DETECTED); + }); + }); + + // ──────────────────────────────────────────────────────────────────────────── + // Rate Limiting + // ──────────────────────────────────────────────────────────────────────────── + + describe('⚠️ Rate limiting', () => { + it('throws TooManyRequestsException when rate limit is exceeded', async () => { + // 5 existing submissions in the last 60s → 6th should be rejected + await buildService(basePuzzle, null, 5); + + await expect( + service.submitSolution('user-1', 'puzzle-uuid-001', makeDto()), + ).rejects.toThrow(TooManyRequestsException); + }); + + it('allows submission when under rate limit', async () => { + await buildService(basePuzzle, null, 4); // 4 < 5 → allowed + const result = await service.submitSolution('user-1', 'puzzle-uuid-001', makeDto()); + expect(result.submissionId).toBeDefined(); + }); + }); + + // ──────────────────────────────────────────────────────────────────────────── + // Puzzle Not Found + // ──────────────────────────────────────────────────────────────────────────── + + describe('🔍 Puzzle not found', () => { + it('throws NotFoundException when puzzle does not exist', async () => { + await buildService(null); // findOne returns null + await expect( + service.submitSolution('user-1', 'nonexistent-id', makeDto()), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ──────────────────────────────────────────────────────────────────────────── + // Hint Penalty + // ──────────────────────────────────────────────────────────────────────────── + + describe('💡 Hint penalty scoring', () => { + beforeEach(() => buildService()); + + it('reduces score when hints are used', async () => { + const noHints = await service.submitSolution('user-1', 'puzzle-uuid-001', makeDto({ hintsUsed: 0 })); + const withHints = await service.submitSolution('user-1', 'puzzle-uuid-001', makeDto({ hintsUsed: 2 })); + + expect(withHints.rewards!.totalScore).toBeLessThan(noHints.rewards!.totalScore); + }); + + it('hintPenalty is negative in reward breakdown', async () => { + const result = await service.submitSolution('user-1', 'puzzle-uuid-001', makeDto({ hintsUsed: 2 })); + expect(result.rewards!.hintPenalty).toBeLessThan(0); + }); + + it('does NOT award independent_thinker achievement when hints used', async () => { + const result = await service.submitSolution('user-1', 'puzzle-uuid-001', makeDto({ hintsUsed: 1 })); + expect(result.rewards!.achievements).not.toContain('independent_thinker'); + }); + }); + + // ──────────────────────────────────────────────────────────────────────────── + // Answer Normalisation + // ──────────────────────────────────────────────────────────────────────────── + + describe('🔑 Answer normalisation (hash comparison)', () => { + it('accepts answer with different key ordering (object normalisation)', async () => { + // correctAnswer = 'correct_answer' (string) — test object answer normalisation + const objectPuzzle = { + ...basePuzzle, + content: { type: 'logic-grid', correctAnswer: { b: 2, a: 1 }, explanation: '' }, + }; + await buildService(objectPuzzle as any); + + const result = await service.submitSolution( + 'user-1', + 'puzzle-uuid-001', + makeDto({ answer: { a: 1, b: 2 } }), // same values, different key order + ); + expect(result.isCorrect).toBe(true); + }); + + it('is case-insensitive for string answers', async () => { + await buildService(); + const result = await service.submitSolution( + 'user-1', + 'puzzle-uuid-001', + makeDto({ answer: 'CORRECT_ANSWER' }), + ); + expect(result.isCorrect).toBe(true); + }); + + it('ignores surrounding whitespace in string answers', async () => { + await buildService(); + const result = await service.submitSolution( + 'user-1', + 'puzzle-uuid-001', + makeDto({ answer: ' correct_answer ' }), + ); + expect(result.isCorrect).toBe(true); + }); + }); + + // ──────────────────────────────────────────────────────────────────────────── + // Submission History + // ──────────────────────────────────────────────────────────────────────────── + + describe('📜 Submission history', () => { + it('returns paginated history for a user', async () => { + await buildService(); + attemptRepo.findAndCount.mockResolvedValue([ + [{ id: 'a1', status: SolutionAttemptStatus.CORRECT, scoreAwarded: 100 }], + 1, + ]); + const history = await service.getSubmissionHistory('user-1', undefined, 1, 20); + expect(history.items).toHaveLength(1); + expect(history.total).toBe(1); + expect(history.totalPages).toBe(1); + }); + + it('filters history by puzzleId when provided', async () => { + await buildService(); + attemptRepo.findAndCount.mockResolvedValue([[], 0]); + await service.getSubmissionHistory('user-1', 'puzzle-uuid-001', 1, 20); + + expect(attemptRepo.findAndCount).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ puzzleId: 'puzzle-uuid-001' }), + }), + ); + }); + + it('returns empty list when no attempts exist', async () => { + await buildService(); + const history = await service.getSubmissionHistory('user-1'); + expect(history.items).toHaveLength(0); + expect(history.total).toBe(0); + }); + }); +});