diff --git a/src/game-engine/controllers/practice-mode.controller.ts b/src/game-engine/controllers/practice-mode.controller.ts new file mode 100644 index 00000000..91cb4245 --- /dev/null +++ b/src/game-engine/controllers/practice-mode.controller.ts @@ -0,0 +1,74 @@ +import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; +import type { CreatePracticeSessionInput } from '../services/practice-mode.service'; +import { PracticeModeService } from '../services/practice-mode.service'; +import { DifficultyLevel } from '../types/puzzle.types'; + +@Controller('puzzles/practice') +export class PracticeModeController { + constructor(private readonly practiceMode: PracticeModeService) {} + + @Post() + startPractice(@Body() input: CreatePracticeSessionInput) { + return this.practiceMode.startPractice(input); + } + + @Post(':practiceSessionId/player/:playerId/attempts') + submitAttempt( + @Param('practiceSessionId') practiceSessionId: string, + @Param('playerId') playerId: string, + @Body() body: { answer: unknown }, + ) { + return this.practiceMode.submitAttempt( + practiceSessionId, + playerId, + body.answer, + ); + } + + @Get(':practiceSessionId/player/:playerId/progress') + getProgress( + @Param('practiceSessionId') practiceSessionId: string, + @Param('playerId') playerId: string, + ) { + return this.practiceMode.getProgress(practiceSessionId, playerId); + } + + @Get(':practiceSessionId/player/:playerId/hints/:level') + getHint( + @Param('practiceSessionId') practiceSessionId: string, + @Param('playerId') playerId: string, + @Param('level') level: string, + ) { + return this.practiceMode.getHint( + practiceSessionId, + playerId, + Number(level), + ); + } + + @Post(':practiceSessionId/player/:playerId/replay') + replay( + @Param('practiceSessionId') practiceSessionId: string, + @Param('playerId') playerId: string, + ) { + return this.practiceMode.replay(practiceSessionId, playerId); + } + + @Patch(':practiceSessionId/player/:playerId/difficulty') + adjustDifficulty( + @Param('practiceSessionId') practiceSessionId: string, + @Param('playerId') playerId: string, + @Body() body: { difficulty: DifficultyLevel }, + ) { + return this.practiceMode.adjustDifficulty( + practiceSessionId, + playerId, + body.difficulty, + ); + } + + @Get('player/:playerId/analytics') + getPlayerAnalytics(@Param('playerId') playerId: string) { + return this.practiceMode.getPlayerAnalytics(playerId); + } +} diff --git a/src/game-engine/entities/practice-attempt.entity.ts b/src/game-engine/entities/practice-attempt.entity.ts new file mode 100644 index 00000000..76feaa03 --- /dev/null +++ b/src/game-engine/entities/practice-attempt.entity.ts @@ -0,0 +1,24 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +@Entity('practice_attempts') +@Index(['practiceSessionId', 'createdAt']) +export class PracticeAttempt { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + practiceSessionId: string; + + @Column({ type: 'boolean' }) + isCorrect: boolean; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + createdAt: Date; +} diff --git a/src/game-engine/entities/practice-session.entity.ts b/src/game-engine/entities/practice-session.entity.ts new file mode 100644 index 00000000..3e4da735 --- /dev/null +++ b/src/game-engine/entities/practice-session.entity.ts @@ -0,0 +1,69 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import type { DifficultyLevel, PuzzleType } from '../types/puzzle.types'; + +@Entity('practice_sessions') +@Index(['playerId', 'updatedAt']) +export class PracticeSession { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + playerId: string; + + @Column({ type: 'varchar', length: 255 }) + @Index() + puzzleId: string; + + @Column({ type: 'varchar', length: 50 }) + puzzleType: PuzzleType; + + @Column({ type: 'int' }) + difficulty: DifficultyLevel; + + @Column({ type: 'jsonb' }) + content: Record; + + @Column({ type: 'jsonb' }) + solution: unknown; + + @Column({ type: 'jsonb', default: [] }) + hints: string[]; + + @Column({ type: 'varchar', nullable: true }) + solutionGuide?: string; + + @Column({ type: 'int', default: 0 }) + attemptsCount: number; + + @Column({ type: 'int', default: 0 }) + correctAttempts: number; + + @Column({ type: 'int', default: 0 }) + hintsRequested: number; + + @Column({ type: 'int', default: 0 }) + replays: number; + + @Column({ type: 'int', default: 0 }) + totalTimeSpent: number; + + @Column({ type: 'boolean', default: false }) + completed: boolean; + + @Column({ type: 'timestamp with time zone' }) + startedAt: Date; + + @CreateDateColumn({ type: 'timestamp with time zone' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamp with time zone' }) + updatedAt: Date; +} diff --git a/src/game-engine/game-engine.module.ts b/src/game-engine/game-engine.module.ts index e09aa038..16d56e8d 100644 --- a/src/game-engine/game-engine.module.ts +++ b/src/game-engine/game-engine.module.ts @@ -22,6 +22,11 @@ import { PuzzleState } from './entities/puzzle-state.entity'; import { PlayerProgress } from './entities/player-progress.entity'; import { GameSession } from './entities/game-session.entity'; import { PuzzleAnalytics } from './entities/puzzle-analytics.entity'; +import { PracticeSession } from './entities/practice-session.entity'; +import { PracticeAttempt } from './entities/practice-attempt.entity'; +import { PracticeModeService } from './services/practice-mode.service'; +import { PracticeModeController } from './controllers/practice-mode.controller'; +import { Puzzle } from '../puzzles/entities/puzzle.entity'; import { gameEngineConfig } from './config/game-engine.config'; import { EnergyModule } from '../energy/energy.module'; @@ -34,6 +39,9 @@ import { EnergyModule } from '../energy/energy.module'; PlayerProgress, GameSession, PuzzleAnalytics, + PracticeSession, + PracticeAttempt, + Puzzle, ]), EnergyModule, ], @@ -52,8 +60,14 @@ import { EnergyModule } from '../energy/energy.module'; PuzzleRegistryService, ScoringService, AchievementsService, + PracticeModeService, + ], + controllers: [ + PuzzleController, + GameStateController, + AnalyticsController, + PracticeModeController, ], - controllers: [PuzzleController, GameStateController, AnalyticsController], exports: [ PuzzleEngineService, StateManagementService, @@ -69,6 +83,7 @@ import { EnergyModule } from '../energy/energy.module'; PuzzleRegistryService, ScoringService, AchievementsService, + PracticeModeService, ], }) export class GameEngineModule {} diff --git a/src/game-engine/services/practice-mode.service.ts b/src/game-engine/services/practice-mode.service.ts new file mode 100644 index 00000000..c47de3db --- /dev/null +++ b/src/game-engine/services/practice-mode.service.ts @@ -0,0 +1,361 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { IsNull, Not, Repository } from 'typeorm'; +import { PracticeAttempt } from '../entities/practice-attempt.entity'; +import { PracticeSession } from '../entities/practice-session.entity'; +import { DifficultyLevel, PuzzleType } from '../types/puzzle.types'; +import { Puzzle } from '../../puzzles/entities/puzzle.entity'; + +export interface CreatePracticeSessionInput { + playerId: string; + puzzleId: string; + difficulty?: DifficultyLevel; +} + +const DEFAULT_HINTS = [ + 'Identify the key information and constraints in the puzzle.', + 'Work through the problem one step at a time.', + 'Check your result against the puzzle goal.', +]; + +@Injectable() +export class PracticeModeService { + constructor( + @InjectRepository(PracticeSession) + private readonly sessionRepository: Repository, + @InjectRepository(PracticeAttempt) + private readonly attemptRepository: Repository, + @InjectRepository(Puzzle) + private readonly puzzleRepository: Repository, + ) {} + + async startPractice( + input: CreatePracticeSessionInput, + ): Promise> { + if ( + !input || + typeof input.playerId !== 'string' || + !input.playerId || + typeof input.puzzleId !== 'string' || + !input.puzzleId + ) { + throw new BadRequestException('playerId and puzzleId are required'); + } + if ( + input.difficulty !== undefined && + (!Number.isInteger(input.difficulty) || + input.difficulty < DifficultyLevel.BEGINNER || + input.difficulty > DifficultyLevel.IMPOSSIBLE) + ) { + throw new BadRequestException('Invalid puzzle difficulty'); + } + + const puzzle = await this.puzzleRepository.findOne({ + where: { + id: input.puzzleId, + isActive: true, + publishedAt: Not(IsNull()), + }, + }); + if (!puzzle) { + throw new NotFoundException('Published puzzle not found'); + } + const puzzleContent: Record = puzzle.content; + const solution = puzzleContent.correctAnswer; + if (solution === undefined || solution === null) { + throw new BadRequestException( + 'This puzzle does not have a solution for practice feedback', + ); + } + const content = { ...puzzleContent }; + delete content.correctAnswer; + const solutionGuide = + typeof content.explanation === 'string' ? content.explanation : undefined; + delete content.explanation; + const hints = puzzle.hints + ?.slice() + .sort((first, second) => first.order - second.order) + .map((hint) => hint.text); + + const session = await this.sessionRepository.save( + this.sessionRepository.create({ + playerId: input.playerId, + puzzleId: puzzle.id, + puzzleType: this.getPuzzleType(puzzle), + difficulty: + input.difficulty ?? + Math.min( + Math.max(Math.round(puzzle.difficultyRating), 1), + DifficultyLevel.IMPOSSIBLE, + ), + content: { + title: puzzle.title, + description: puzzle.description, + ...content, + }, + solution, + hints: hints?.length ? hints : DEFAULT_HINTS, + solutionGuide, + startedAt: new Date(), + }), + ); + return this.toPublicSession(session); + } + + async submitAttempt( + practiceSessionId: string, + playerId: string, + answer: unknown, + ): Promise> { + const session = await this.getOwnedSession(practiceSessionId, playerId); + const isCorrect = this.matchesSolution(answer, session.solution); + + await this.attemptRepository.save( + this.attemptRepository.create({ + practiceSessionId: session.id, + isCorrect, + }), + ); + + const now = new Date(); + session.attemptsCount += 1; + session.totalTimeSpent += Math.max( + 0, + Math.floor((now.getTime() - session.startedAt.getTime()) / 1000), + ); + session.startedAt = now; + if (isCorrect) { + session.correctAttempts += 1; + session.completed = true; + } + await this.sessionRepository.save(session); + + return { + practiceSessionId: session.id, + correct: isCorrect, + feedback: isCorrect + ? 'Correct. You can keep practicing or replay this puzzle.' + : 'Not quite. Review the puzzle guidance and try again.', + attemptNumber: session.attemptsCount, + completed: session.completed, + ranked: false, + rewardsEarned: false, + }; + } + + async getProgress( + practiceSessionId: string, + playerId: string, + ): Promise> { + const session = await this.getOwnedSession(practiceSessionId, playerId); + const recentAttempts = await this.attemptRepository.find({ + where: { practiceSessionId: session.id }, + order: { createdAt: 'DESC' }, + take: 10, + }); + + return { + ...this.toPublicSession(session), + recentAttempts: recentAttempts.map((attempt) => ({ + correct: attempt.isCorrect, + timestamp: attempt.createdAt, + })), + accuracy: + session.attemptsCount === 0 + ? 0 + : session.correctAttempts / session.attemptsCount, + }; + } + + async getHint( + practiceSessionId: string, + playerId: string, + level: number, + ): Promise> { + if (!Number.isInteger(level) || level < 1) { + throw new BadRequestException('Hint level must be a positive integer'); + } + + const session = await this.getOwnedSession(practiceSessionId, playerId); + const hintIndex = level - 1; + let guidance: string; + let isSolutionGuide = false; + + if (hintIndex < session.hints.length) { + guidance = session.hints[hintIndex]; + } else if (session.solutionGuide) { + guidance = session.solutionGuide; + isSolutionGuide = true; + } else { + throw new NotFoundException('No guidance is available at this level'); + } + + session.hintsRequested += 1; + await this.sessionRepository.save(session); + + return { + level, + guidance, + isSolutionGuide, + ranked: false, + rewardsEarned: false, + }; + } + + async replay( + practiceSessionId: string, + playerId: string, + ): Promise> { + const session = await this.getOwnedSession(practiceSessionId, playerId); + session.replays += 1; + session.completed = false; + session.startedAt = new Date(); + await this.sessionRepository.save(session); + return this.toPublicSession(session); + } + + async adjustDifficulty( + practiceSessionId: string, + playerId: string, + difficulty: DifficultyLevel, + ): Promise> { + if ( + !Number.isInteger(difficulty) || + difficulty < DifficultyLevel.BEGINNER || + difficulty > DifficultyLevel.IMPOSSIBLE + ) { + throw new BadRequestException('Invalid puzzle difficulty'); + } + + const session = await this.getOwnedSession(practiceSessionId, playerId); + session.difficulty = difficulty; + await this.sessionRepository.save(session); + return this.toPublicSession(session); + } + + async getPlayerAnalytics(playerId: string): Promise> { + const sessions = await this.sessionRepository.find({ where: { playerId } }); + const totalAttempts = sessions.reduce( + (total, session) => total + session.attemptsCount, + 0, + ); + const correctAttempts = sessions.reduce( + (total, session) => total + session.correctAttempts, + 0, + ); + + return { + playerId, + mode: 'practice', + ranked: false, + rewardsEarned: false, + sessions: sessions.length, + attempts: totalAttempts, + correctAttempts, + accuracy: totalAttempts === 0 ? 0 : correctAttempts / totalAttempts, + hintsRequested: sessions.reduce( + (total, session) => total + session.hintsRequested, + 0, + ), + replays: sessions.reduce((total, session) => total + session.replays, 0), + totalTimeSpent: sessions.reduce( + (total, session) => total + session.totalTimeSpent, + 0, + ), + difficulty: sessions.reduce((counts: Record, session) => { + counts[session.difficulty] = (counts[session.difficulty] || 0) + 1; + return counts; + }, {}), + }; + } + + private async getOwnedSession( + practiceSessionId: string, + playerId: string, + ): Promise { + const session = await this.sessionRepository.findOne({ + where: { id: practiceSessionId, playerId }, + }); + if (!session) { + throw new NotFoundException('Practice session not found'); + } + return session; + } + + private getPuzzleType(puzzle: Puzzle): PuzzleType { + const aliases: Record = { + logic: PuzzleType.LOGIC_GRID, + logic_grid: PuzzleType.LOGIC_GRID, + 'logic-grid': PuzzleType.LOGIC_GRID, + sequence: PuzzleType.SEQUENCE, + pattern: PuzzleType.PATTERN_MATCHING, + pattern_matching: PuzzleType.PATTERN_MATCHING, + spatial: PuzzleType.SPATIAL, + math: PuzzleType.MATHEMATICAL, + mathematical: PuzzleType.MATHEMATICAL, + word: PuzzleType.WORD_PUZZLE, + word_puzzle: PuzzleType.WORD_PUZZLE, + custom: PuzzleType.CUSTOM, + }; + const candidates = [puzzle.category, puzzle.content?.type] + .filter((value): value is string => typeof value === 'string') + .map((value) => value.toLowerCase().replace(/\s+/g, '_')); + + for (const candidate of candidates) { + const alias = aliases[candidate]; + if (alias) return alias; + } + return PuzzleType.CUSTOM; + } + + private matchesSolution(answer: unknown, solution: unknown): boolean { + return ( + JSON.stringify(this.normalize(answer)) === + JSON.stringify(this.normalize(solution)) + ); + } + + private normalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => this.normalize(item)); + } + if (value !== null && typeof value === 'object') { + return Object.keys(value as Record) + .sort() + .reduce((normalized: Record, key) => { + normalized[key] = this.normalize( + (value as Record)[key], + ); + return normalized; + }, {}); + } + if (typeof value === 'string') { + return value.trim().toLowerCase(); + } + return value; + } + + private toPublicSession(session: PracticeSession): Record { + return { + id: session.id, + playerId: session.playerId, + puzzleId: session.puzzleId, + puzzleType: session.puzzleType, + difficulty: session.difficulty, + content: session.content, + mode: 'practice', + ranked: false, + rewardsEarned: false, + attempts: session.attemptsCount, + correctAttempts: session.correctAttempts, + hintsRequested: session.hintsRequested, + replays: session.replays, + totalTimeSpent: session.totalTimeSpent, + completed: session.completed, + }; + } +} diff --git a/src/game-engine/tests/practice-mode.service.spec.ts b/src/game-engine/tests/practice-mode.service.spec.ts new file mode 100644 index 00000000..22f26b11 --- /dev/null +++ b/src/game-engine/tests/practice-mode.service.spec.ts @@ -0,0 +1,269 @@ +import { NotFoundException } from '@nestjs/common'; +import { PracticeModeService } from '../services/practice-mode.service'; +import { DifficultyLevel, PuzzleType } from '../types/puzzle.types'; +import type { PracticeAttempt } from '../entities/practice-attempt.entity'; +import type { PracticeSession } from '../entities/practice-session.entity'; +import type { Puzzle } from '../../puzzles/entities/puzzle.entity'; + +describe('PracticeModeService', () => { + let service: PracticeModeService; + let sessions: { + create: jest.Mock]>; + save: jest.Mock, [PracticeSession]>; + findOne: jest.Mock< + Promise, + [{ where: { id: string; playerId: string } }] + >; + find: jest.Mock< + Promise, + [{ where: { playerId: string } }] + >; + }; + let attempts: { + create: jest.Mock]>; + save: jest.Mock, [PracticeAttempt]>; + find: jest.Mock< + Promise, + [ + { + where: { practiceSessionId: string }; + order: { createdAt: 'DESC' }; + take: number; + }, + ] + >; + }; + let puzzles: { + findOne: jest.Mock< + Promise, + [ + { + where: { + id: string; + isActive: true; + publishedAt: ReturnType; + }; + }, + ] + >; + }; + let session: PracticeSession; + let puzzle: Puzzle; + + const makeSession = () => + ({ + id: 'practice-1', + playerId: 'player-1', + puzzleId: 'puzzle-1', + puzzleType: PuzzleType.SEQUENCE, + difficulty: DifficultyLevel.MEDIUM, + content: { question: 'What comes next?' }, + solution: { answer: 3 }, + hints: ['Find the pattern', 'Add two each time'], + solutionGuide: 'The sequence increases by two.', + attemptsCount: 0, + correctAttempts: 0, + hintsRequested: 0, + replays: 0, + totalTimeSpent: 0, + completed: false, + startedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + } as PracticeSession); + + beforeEach(() => { + session = makeSession(); + puzzle = { + id: 'puzzle-1', + title: 'Sequence', + description: 'Continue the sequence.', + category: 'sequence', + difficultyRating: DifficultyLevel.MEDIUM, + content: { + type: 'fill-blank', + question: 'What comes next?', + correctAnswer: { answer: 3 }, + explanation: 'The sequence increases by two.', + }, + hints: [ + { order: 2, text: 'Add two each time' }, + { order: 1, text: 'Find the pattern' }, + ], + isActive: true, + publishedAt: new Date(), + } as Puzzle; + sessions = { + create: jest.fn((input: Partial) => + Object.assign(makeSession(), input), + ), + save: jest.fn((value: PracticeSession) => { + if (!value.id) value.id = 'practice-1'; + return Promise.resolve(value); + }), + findOne: jest.fn(() => Promise.resolve(session)), + find: jest.fn(() => Promise.resolve([session])), + }; + attempts = { + create: jest.fn( + (input: Partial) => ({ ...input } as PracticeAttempt), + ), + save: jest.fn((value: PracticeAttempt) => Promise.resolve(value)), + find: jest.fn(() => Promise.resolve([])), + }; + puzzles = { findOne: jest.fn(() => Promise.resolve(puzzle)) }; + service = new PracticeModeService( + sessions as never, + attempts as never, + puzzles as never, + ); + }); + + it('starts a separate practice session without exposing its solution', async () => { + const result = await service.startPractice({ + playerId: 'player-1', + puzzleId: 'puzzle-1', + }); + + expect(puzzles.findOne).toHaveBeenCalledTimes(1); + expect(sessions.save).toHaveBeenCalledTimes(1); + expect(sessions.create).toHaveBeenCalledWith( + expect.objectContaining({ + solution: { answer: 3 }, + hints: ['Find the pattern', 'Add two each time'], + }), + ); + expect(result).not.toHaveProperty('content.correctAnswer'); + expect(result).not.toHaveProperty('content.explanation'); + expect(result).toMatchObject({ + mode: 'practice', + ranked: false, + rewardsEarned: false, + }); + expect(result).not.toHaveProperty('solution'); + }); + + it('rejects unpublished puzzles rather than accepting client-provided answers', async () => { + puzzles.findOne.mockResolvedValue(null); + await expect( + service.startPractice({ + playerId: 'player-1', + puzzleId: 'puzzle-1', + }), + ).rejects.toBeInstanceOf(NotFoundException); + expect(sessions.save).not.toHaveBeenCalled(); + }); + + it('records unlimited attempts and returns immediate feedback without rewards', async () => { + for (let index = 0; index < 25; index += 1) { + await service.submitAttempt('practice-1', 'player-1', 0); + } + const result = await service.submitAttempt('practice-1', 'player-1', { + answer: 3, + }); + + expect(session.attemptsCount).toBe(26); + expect(session.correctAttempts).toBe(1); + expect(session.completed).toBe(true); + expect(attempts.save).toHaveBeenCalledTimes(26); + expect(result).toMatchObject({ + correct: true, + attemptNumber: 26, + ranked: false, + rewardsEarned: false, + }); + }); + + it('compares structured answers independent of object key ordering', async () => { + session.solution = { left: 1, right: { top: 2, bottom: 3 } }; + const result = await service.submitAttempt('practice-1', 'player-1', { + right: { bottom: 3, top: 2 }, + left: 1, + }); + + expect(result).toMatchObject({ correct: true, completed: true }); + }); + + it('normalizes text answers consistently with ranked puzzle validation', async () => { + session.solution = 'Correct answer'; + const result = await service.submitAttempt( + 'practice-1', + 'player-1', + ' correct ANSWER ', + ); + + expect(result).toMatchObject({ correct: true, completed: true }); + }); + + it('returns progressive hints without consuming ranked hint allowances', async () => { + const first = await service.getHint('practice-1', 'player-1', 1); + const guide = await service.getHint('practice-1', 'player-1', 3); + + expect(first).toMatchObject({ + guidance: 'Find the pattern', + isSolutionGuide: false, + ranked: false, + rewardsEarned: false, + }); + expect(guide).toMatchObject({ + guidance: 'The sequence increases by two.', + isSolutionGuide: true, + }); + expect(session.hintsRequested).toBe(2); + }); + + it('tracks practice progress and recent attempt history', async () => { + session.attemptsCount = 4; + session.correctAttempts = 2; + attempts.find.mockResolvedValue([ + { isCorrect: true, createdAt: new Date() } as PracticeAttempt, + ]); + const progress = await service.getProgress('practice-1', 'player-1'); + + expect(progress).toMatchObject({ + attempts: 4, + correctAttempts: 2, + accuracy: 0.5, + }); + expect(progress).toHaveProperty('recentAttempts'); + }); + + it('replays without clearing accumulated practice progress', async () => { + session.attemptsCount = 3; + session.completed = true; + const replay = await service.replay('practice-1', 'player-1'); + + expect(session.attemptsCount).toBe(3); + expect(session.replays).toBe(1); + expect(session.completed).toBe(false); + expect(replay).toMatchObject({ attempts: 3, replays: 1 }); + }); + + it('allows difficulty adjustment and reports non-ranked practice analytics', async () => { + await service.adjustDifficulty( + 'practice-1', + 'player-1', + DifficultyLevel.HARD, + ); + session.attemptsCount = 2; + session.correctAttempts = 1; + const analytics = await service.getPlayerAnalytics('player-1'); + + expect(session.difficulty).toBe(DifficultyLevel.HARD); + expect(analytics).toMatchObject({ + mode: 'practice', + ranked: false, + rewardsEarned: false, + attempts: 2, + correctAttempts: 1, + accuracy: 0.5, + }); + }); + + it('does not allow another player to read a practice session', async () => { + sessions.findOne.mockResolvedValue(null); + await expect( + service.getProgress('practice-1', 'player-2'), + ).rejects.toBeInstanceOf(NotFoundException); + }); +}); diff --git a/src/migrations/1760900000000-create-practice-mode-tables.ts b/src/migrations/1760900000000-create-practice-mode-tables.ts new file mode 100644 index 00000000..aea95f90 --- /dev/null +++ b/src/migrations/1760900000000-create-practice-mode-tables.ts @@ -0,0 +1,125 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableForeignKey, + TableIndex, +} from 'typeorm'; + +export class CreatePracticeModeTables1760900000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'practice_sessions', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { name: 'playerId', type: 'uuid' }, + { name: 'puzzleId', type: 'varchar', length: '255' }, + { name: 'puzzleType', type: 'varchar', length: '50' }, + { name: 'difficulty', type: 'integer' }, + { name: 'content', type: 'jsonb' }, + { name: 'solution', type: 'jsonb' }, + { name: 'hints', type: 'jsonb', default: "'[]'::jsonb" }, + { name: 'solutionGuide', type: 'varchar', isNullable: true }, + { name: 'attemptsCount', type: 'integer', default: 0 }, + { name: 'correctAttempts', type: 'integer', default: 0 }, + { name: 'hintsRequested', type: 'integer', default: 0 }, + { name: 'replays', type: 'integer', default: 0 }, + { name: 'totalTimeSpent', type: 'integer', default: 0 }, + { name: 'completed', type: 'boolean', default: false }, + { name: 'startedAt', type: 'timestamptz' }, + { + name: 'createdAt', + type: 'timestamptz', + default: 'now()', + }, + { + name: 'updatedAt', + type: 'timestamptz', + default: 'now()', + }, + ], + }), + true, + ); + await queryRunner.createIndex( + 'practice_sessions', + new TableIndex({ + name: 'IDX_practice_sessions_playerId', + columnNames: ['playerId'], + }), + ); + await queryRunner.createIndex( + 'practice_sessions', + new TableIndex({ + name: 'IDX_practice_sessions_puzzleId', + columnNames: ['puzzleId'], + }), + ); + await queryRunner.createIndex( + 'practice_sessions', + new TableIndex({ + name: 'IDX_practice_sessions_playerId_updatedAt', + columnNames: ['playerId', 'updatedAt'], + }), + ); + + await queryRunner.createTable( + new Table({ + name: 'practice_attempts', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { name: 'practiceSessionId', type: 'uuid' }, + { name: 'isCorrect', type: 'boolean' }, + { + name: 'createdAt', + type: 'timestamptz', + default: 'now()', + }, + ], + foreignKeys: [ + new TableForeignKey({ + columnNames: ['practiceSessionId'], + referencedTableName: 'practice_sessions', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ], + }), + true, + ); + await queryRunner.createIndex( + 'practice_attempts', + new TableIndex({ + name: 'IDX_practice_attempts_practiceSessionId', + columnNames: ['practiceSessionId'], + }), + ); + await queryRunner.createIndex( + 'practice_attempts', + new TableIndex({ + name: 'IDX_practice_attempts_session_createdAt', + columnNames: ['practiceSessionId', 'createdAt'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('practice_attempts'); + await queryRunner.dropTable('practice_sessions'); + } +}