diff --git a/src/achievements/achievements.module.ts b/src/achievements/achievements.module.ts index 5fc7ac8..3f37423 100644 --- a/src/achievements/achievements.module.ts +++ b/src/achievements/achievements.module.ts @@ -3,13 +3,14 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AchievementsService } from './achievements.service'; import { AchievementsController } from './achievements.controller'; +import { PlayerEventsModule } from '../player-events/player-events.module'; import { Achievement } from './entities/achievement.entity'; import { UserAchievement } from './entities/user-achievement.entity'; import { NotificationsModule } from '../notifications/notifications.module'; import { AchievementConditionEngine } from './achievement-condition.engine'; @Module({ - imports: [TypeOrmModule.forFeature([Achievement, UserAchievement]), NotificationsModule], + imports: [TypeOrmModule.forFeature([Achievement, UserAchievement]), NotificationsModule, PlayerEventsModule], controllers: [AchievementsController], providers: [AchievementsService, AchievementConditionEngine], exports: [AchievementsService], diff --git a/src/app.module.ts b/src/app.module.ts index 299ccab..7fe6fc9 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -50,6 +50,7 @@ import { EnergyModule } from './energy/energy.module'; import { SkillRatingModule } from './skill-rating/skill-rating.module'; import { WalletAuthModule } from './auth/wallet-auth.module'; import { XpModule } from './xp/xp.module'; +import { PlayerEventsModule } from './player-events/player-events.module'; @Module({ imports: [ @@ -128,8 +129,7 @@ import { XpModule } from './xp/xp.module'; PuzzleModule, EventModule, SeasonalEventsModule, - MultiplayerModule, - RecommendationsModule, + MultiplayerModule, PlayerEventsModule, RecommendationsModule, AntiCheatModule, QuestsModule, IntegrationsModule, diff --git a/src/game-session/game-session.module.ts b/src/game-session/game-session.module.ts index 2be62e5..e397bd5 100644 --- a/src/game-session/game-session.module.ts +++ b/src/game-session/game-session.module.ts @@ -1,7 +1,8 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { GameSession } from './entities/game-session.entity'; import { Spectator } from './entities/spectator.entity'; +import { PlayerEventsModule } from '../player-events/player-events.module'; import { GameSessionService } from './services/game-session.service'; import { SpectatorService } from './services/spectator.service'; import { CleanupSessionJob } from './services/cleanup-session.job'; @@ -9,7 +10,7 @@ import { AutosaveSessionJob } from './services/autosave-session.job'; import { GameSessionController } from './controllers/game-session.controller'; @Module({ - imports: [TypeOrmModule.forFeature([GameSession, Spectator])], + imports: [TypeOrmModule.forFeature([GameSession, Spectator]), forwardRef(() => PlayerEventsModule)], controllers: [GameSessionController], providers: [ GameSessionService, diff --git a/src/game-session/services/game-session.service.ts b/src/game-session/services/game-session.service.ts index 336d25d..af9f570 100644 --- a/src/game-session/services/game-session.service.ts +++ b/src/game-session/services/game-session.service.ts @@ -3,12 +3,14 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, LessThan } from 'typeorm'; import { GameSession } from '../entities/game-session.entity'; +import { PlayerEventsService } from '../../player-events/player-events.service'; @Injectable() export class GameSessionService { constructor( @InjectRepository(GameSession) private readonly sessionRepo: Repository, + private readonly playerEventsService: PlayerEventsService, ) {} async create(userId: string) { @@ -18,7 +20,19 @@ export class GameSessionService { state: {}, lastActiveAt: new Date(), }); - return this.sessionRepo.save(session); + const savedSession = await this.sessionRepo.save(session); + + await this.playerEventsService.emitPlayerEvent({ + userId, + sessionId: savedSession.id, + eventType: 'puzzle.started', + payload: { + sessionId: savedSession.id, + startedAt: savedSession.createdAt || new Date(), + }, + }); + + return savedSession; } async updateState(sessionId: string, partialState: Record) { @@ -44,10 +58,29 @@ export class GameSessionService { session.status = status; session.lastActiveAt = new Date(); - return this.sessionRepo.save(session); + const savedSession = await this.sessionRepo.save(session); + + if (status === 'ABANDONED') { + await this.playerEventsService.emitPlayerEvent({ + userId: savedSession.userId, + sessionId: savedSession.id, + eventType: 'puzzle.abandoned', + payload: { + sessionId: savedSession.id, + reason: 'session ended as abandoned', + endedAt: savedSession.lastActiveAt, + }, + }); + } + + return savedSession; } async getActiveSessions() { return this.sessionRepo.find({ where: { status: 'IN_PROGRESS' } }); } + + async getById(sessionId: string) { + return this.sessionRepo.findOne({ where: { id: sessionId } }); + } } diff --git a/src/hints/hints.module.ts b/src/hints/hints.module.ts index 6d931ab..bf556ff 100644 --- a/src/hints/hints.module.ts +++ b/src/hints/hints.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { HintsService } from './hints.service'; import { HintsController } from './hints.controller'; +import { PlayerEventsModule } from '../player-events/player-events.module'; import { Hint } from './entities/hint.entity'; import { HintUsage } from './entities/hint-usage.entity'; import { HintTemplate } from './entities/hint-template.entity'; @@ -11,6 +12,7 @@ import { PuzzlesModule } from '../puzzles/puzzles.module'; imports: [ TypeOrmModule.forFeature([Hint, HintUsage, HintTemplate]), PuzzlesModule, + PlayerEventsModule, ], controllers: [HintsController], providers: [HintsService], diff --git a/src/hints/hints.service.ts b/src/hints/hints.service.ts index 604d269..9075e0c 100644 --- a/src/hints/hints.service.ts +++ b/src/hints/hints.service.ts @@ -1,6 +1,7 @@ import { Injectable, BadRequestException, ForbiddenException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, FindOptionsWhere, MoreThanOrEqual } from 'typeorm'; +import { PlayerEventsService } from '../player-events/player-events.service'; import { Hint } from './entities/hint.entity'; import { HintUsage } from './entities/hint-usage.entity'; import { HintTemplate } from './entities/hint-template.entity'; @@ -18,6 +19,7 @@ export class HintsService { private readonly usageRepo: Repository, @InjectRepository(HintTemplate) private readonly templateRepo: Repository, + private readonly playerEventsService: PlayerEventsService, ) {} async createHint(dto: CreateHintDto): Promise { @@ -92,6 +94,21 @@ export class HintsService { }, }); + // Emit audit event for hint usage + void this.playerEventsService.emitPlayerEvent({ + userId: dto.userId, + sessionId: null, + eventType: 'hint.used', + payload: { + puzzleId: dto.puzzleId, + hintId: selected.id, + attemptNumber: dto.attemptNumber, + timeSpent: dto.timeSpent, + puzzleState: dto.puzzleState, + playerState: dto.playerState, + }, + }); + return selected; } diff --git a/src/player-events/dto/player-action-event.dto.ts b/src/player-events/dto/player-action-event.dto.ts new file mode 100644 index 0000000..14121b3 --- /dev/null +++ b/src/player-events/dto/player-action-event.dto.ts @@ -0,0 +1,33 @@ +import { IsUUID, IsEnum, IsObject, IsOptional, IsNumber, Min, Max, IsString } from 'class-validator'; +import { Type } from 'class-transformer'; +import { PlayerActionEventType } from '../entities/player-action-event.entity'; + +export class CreatePlayerActionEventDto { + @IsUUID() + userId: string; + + @IsUUID() + @IsOptional() + sessionId?: string; + + @IsEnum(['puzzle.started', 'puzzle.solved', 'puzzle.abandoned', 'hint.used', 'answer.submitted', 'achievement.unlocked']) + eventType: PlayerActionEventType; + + @IsObject() + payload: Record; +} + +export class PagedQueryDto { + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + @Max(100) + limit?: number = 20; +} diff --git a/src/player-events/entities/player-action-event.entity.ts b/src/player-events/entities/player-action-event.entity.ts new file mode 100644 index 0000000..d94fbc4 --- /dev/null +++ b/src/player-events/entities/player-action-event.entity.ts @@ -0,0 +1,35 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm'; + +export type PlayerActionEventType = + | 'puzzle.started' + | 'puzzle.solved' + | 'puzzle.abandoned' + | 'hint.used' + | 'answer.submitted' + | 'achievement.unlocked'; + +@Entity('player_action_events') +@Index(['userId', 'eventType', 'timestamp']) +export class PlayerActionEvent { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + @Index() + userId: string; + + @Column({ type: 'uuid', nullable: true }) + @Index() + sessionId: string; + + @Column({ type: 'varchar', length: 50 }) + @Index() + eventType: PlayerActionEventType; + + @Column({ type: 'jsonb', default: {} }) + payload: Record; + + @CreateDateColumn({ type: 'timestamptz' }) + @Index() + timestamp: Date; +} diff --git a/src/player-events/player-events.controller.ts b/src/player-events/player-events.controller.ts new file mode 100644 index 0000000..3be30b8 --- /dev/null +++ b/src/player-events/player-events.controller.ts @@ -0,0 +1,45 @@ +import { Controller, Get, Param, Query, Request, UseGuards, ForbiddenException } from '@nestjs/common'; +import { PlayerEventsService } from './player-events.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { UserRole } from '../auth/constants'; +import { GameSessionService } from '../game-session/services/game-session.service'; +import { PagedQueryDto } from './dto/player-action-event.dto'; + +@Controller() +@UseGuards(JwtAuthGuard, RolesGuard) +export class PlayerEventsController { + constructor( + private readonly playerEventsService: PlayerEventsService, + private readonly gameSessionService: GameSessionService, + ) {} + + @Get('players/:id/events') + @Roles(UserRole.ADMIN) + async getPlayerEvents(@Param('id') userId: string, @Query() query: PagedQueryDto) { + const page = query.page || 1; + const limit = query.limit || 20; + return this.playerEventsService.getEventsByPlayer(userId, page, limit); + } + + @Get('sessions/:id/events') + async getSessionEvents(@Param('id') sessionId: string, @Query() query: PagedQueryDto, @Request() req: any) { + const page = query.page || 1; + const limit = query.limit || 50; + + // Only admins or the session owner can fetch session events + const session = await this.gameSessionService.getById(sessionId); + if (!session) { + throw new ForbiddenException('Session not found or access denied'); + } + + const requester = req.user; + const isAdmin = requester?.role?.name === UserRole.ADMIN; + if (!isAdmin && session.userId !== requester.id) { + throw new ForbiddenException('Access denied'); + } + + return this.playerEventsService.getEventsBySession(sessionId, page, limit); + } +} diff --git a/src/player-events/player-events.module.ts b/src/player-events/player-events.module.ts new file mode 100644 index 0000000..2d82e3b --- /dev/null +++ b/src/player-events/player-events.module.ts @@ -0,0 +1,15 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { PlayerEventsService } from './player-events.service'; +import { PlayerEventsController } from './player-events.controller'; +import { PlayerActionEvent } from './entities/player-action-event.entity'; +import { GameSession } from '../game-session/entities/game-session.entity'; +import { GameSessionModule } from '../game-session/game-session.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([PlayerActionEvent, GameSession]), forwardRef(() => GameSessionModule)], + controllers: [PlayerEventsController], + providers: [PlayerEventsService], + exports: [PlayerEventsService], +}) +export class PlayerEventsModule {} diff --git a/src/player-events/player-events.service.ts b/src/player-events/player-events.service.ts new file mode 100644 index 0000000..09862b9 --- /dev/null +++ b/src/player-events/player-events.service.ts @@ -0,0 +1,161 @@ +import { Injectable, BadRequestException, ForbiddenException, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { PlayerActionEvent, PlayerActionEventType } from './entities/player-action-event.entity'; +import { CreatePlayerActionEventDto } from './dto/player-action-event.dto'; + +@Injectable() +export class PlayerEventsService { + private readonly logger = new Logger(PlayerEventsService.name); + private readonly queue: PlayerActionEvent[] = []; + private isProcessing = false; + + constructor( + @InjectRepository(PlayerActionEvent) + private readonly playerActionEventRepository: Repository, + private readonly eventEmitter: EventEmitter2, + ) {} + + async emitPlayerEvent(createDto: CreatePlayerActionEventDto): Promise { + this.validatePayload(createDto.eventType, createDto.payload); + + const event = this.playerActionEventRepository.create({ + userId: createDto.userId, + sessionId: createDto.sessionId || null, + eventType: createDto.eventType, + payload: createDto.payload, + }); + + // Push for asynchronous persistence + this.enqueue(event); + + // Publish immediately on internal event bus + this.eventEmitter.emit(createDto.eventType, { + userId: createDto.userId, + sessionId: createDto.sessionId, + payload: createDto.payload, + timestamp: new Date(), + }); + + return event; + } + + private enqueue(event: PlayerActionEvent) { + this.queue.push(event); + if (!this.isProcessing) { + this.isProcessing = true; + setImmediate(() => void this.processQueue()); + } + } + + private async processQueue() { + while (this.queue.length > 0) { + const item = this.queue.shift(); + if (!item) continue; + try { + await this.playerActionEventRepository.save(item); + } catch (error) { + this.logger.error('Failed to persist PlayerActionEvent', error as any); + } + } + this.isProcessing = false; + } + + async getEventsByPlayer(userId: string, page = 1, limit = 20) { + const [items, total] = await this.playerActionEventRepository.findAndCount({ + where: { userId }, + order: { timestamp: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; + } + + async getEventsBySession(sessionId: string, page = 1, limit = 50) { + const [items, total] = await this.playerActionEventRepository.findAndCount({ + where: { sessionId }, + order: { timestamp: 'ASC' }, + skip: (page - 1) * limit, + take: limit, + }); + return { items, total, page, limit, totalPages: Math.ceil(total / limit) }; + } + + async computeAggregatesForPlayer(userId: string) { + const events = await this.playerActionEventRepository.find({ where: { userId } }); + + const aggregates = { + totalHintsUsed: 0, + totalSolves: 0, + totalAbandoned: 0, + totalAnswerSubmissions: 0, + totalSolveTimeSeconds: 0, + }; + + for (const ev of events) { + switch (ev.eventType) { + case 'hint.used': { + aggregates.totalHintsUsed += Number(ev.payload?.count ?? 1); + break; + } + case 'puzzle.solved': { + aggregates.totalSolves += 1; + aggregates.totalSolveTimeSeconds += Number(ev.payload?.solveTimeSeconds ?? 0); + break; + } + case 'puzzle.abandoned': { + aggregates.totalAbandoned += 1; + break; + } + case 'answer.submitted': { + aggregates.totalAnswerSubmissions += 1; + break; + } + } + } + + return aggregates; + } + + private validatePayload(eventType: PlayerActionEventType, payload: Record) { + if (!payload || typeof payload !== 'object') { + throw new BadRequestException('Payload must be a JSON object'); + } + + switch (eventType) { + case 'puzzle.started': + if (!payload.puzzleId || !payload.startedAt) { + throw new BadRequestException('payload must include puzzleId and startedAt'); + } + break; + case 'puzzle.solved': + if (!payload.puzzleId || payload.solveTimeSeconds == null) { + throw new BadRequestException('payload must include puzzleId and solveTimeSeconds'); + } + break; + case 'puzzle.abandoned': + if (!payload.puzzleId || !payload.reason) { + throw new BadRequestException('payload must include puzzleId and reason'); + } + break; + case 'hint.used': + if (!payload.puzzleId || !payload.hintId) { + throw new BadRequestException('payload must include puzzleId and hintId'); + } + break; + case 'answer.submitted': + if (!payload.puzzleId || payload.correct == null) { + throw new BadRequestException('payload must include puzzleId and correct'); + } + break; + case 'achievement.unlocked': + if (!payload.achievementId) { + throw new BadRequestException('payload must include achievementId'); + } + break; + default: + throw new BadRequestException('Unknown event type'); + } + } +} diff --git a/src/puzzles/puzzles.module.ts b/src/puzzles/puzzles.module.ts index c0c900e..760c4cb 100644 --- a/src/puzzles/puzzles.module.ts +++ b/src/puzzles/puzzles.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { PuzzlesService } from './puzzles.service'; import { PuzzlesController } from './puzzles.controller'; import { CommunityPuzzlesModule } from './community-puzzles.module'; +import { PlayerEventsModule } from '../player-events/player-events.module'; import { Puzzle } from './entities/puzzle.entity'; import { PuzzleProgress } from '../game-logic/entities/puzzle-progress.entity'; import { PuzzleRating } from './entities/puzzle-rating.entity'; @@ -55,6 +56,7 @@ import { XpModule } from '../xp/xp.module'; ]), AntiCheatModule, XpModule, + PlayerEventsModule, ], controllers: [ PuzzlesController, diff --git a/src/puzzles/services/solution-submission.service.ts b/src/puzzles/services/solution-submission.service.ts index fadbc3b..a090f08 100644 --- a/src/puzzles/services/solution-submission.service.ts +++ b/src/puzzles/services/solution-submission.service.ts @@ -24,6 +24,7 @@ import { import { AntiCheatService } from '../../anti-cheat/services/anti-cheat.service'; import { ViolationType } from '../../anti-cheat/constants'; import { XpService } from '../../xp/xp.service'; +import { PlayerEventsService } from '../../player-events/player-events.service'; // ──────────────────────────────────────────────────────────────────────────── // Constants @@ -52,6 +53,7 @@ export class SolutionSubmissionService { private readonly dataSource: DataSource, private readonly antiCheatService: AntiCheatService, private readonly xpService: XpService, + private readonly playerEventsService: PlayerEventsService, ) { } // ────────────────────────────────────────────────────────────────────────── @@ -201,6 +203,23 @@ export class SolutionSubmissionService { explanation: isCorrect ? (puzzle.content?.explanation ?? undefined) : undefined, }; + // Track every answer submission as an audit event + void this.playerEventsService.emitPlayerEvent({ + userId, + sessionId: null, + eventType: 'answer.submitted', + payload: { + puzzleId, + answer: dto.answer, + status: finalStatus, + correct: isCorrect && !fraudResult.isFraud, + timeTakenSeconds, + hintsUsed: dto.hintsUsed ?? 0, + scoreAwarded, + fraud: fraudResult.isFraud, + }, + }); + if (result.isCorrect) { await this.xpService.awardPuzzleCompletionXp({ userId, @@ -210,6 +229,34 @@ export class SolutionSubmissionService { sourceEventId: attempt.id, solvedAt: now, }); + + // Record a specific puzzle.solved event for analytics and audit + void this.playerEventsService.emitPlayerEvent({ + userId, + sessionId: null, + eventType: 'puzzle.solved', + payload: { + puzzleId, + solveTimeSeconds: timeTakenSeconds, + hintsUsed: dto.hintsUsed ?? 0, + scoreAwarded, + }, + }); + + // Each achievement unlocked should produce an audit event + for (const achievement of result.rewards?.achievements ?? []) { + void this.playerEventsService.emitPlayerEvent({ + userId, + sessionId: null, + eventType: 'achievement.unlocked', + payload: { + puzzleId, + achievementId: achievement, + timeTakenSeconds, + hintsUsed: dto.hintsUsed ?? 0, + }, + }); + } } if (fraudResult.isFraud) { diff --git a/src/puzzles/tests/solution-submission.service.spec.ts b/src/puzzles/tests/solution-submission.service.spec.ts index a9ef5f6..df2e61a 100644 --- a/src/puzzles/tests/solution-submission.service.spec.ts +++ b/src/puzzles/tests/solution-submission.service.spec.ts @@ -4,7 +4,7 @@ import { DataSource } from 'typeorm'; import { ConflictException, NotFoundException, - TooManyRequestsException, + HttpException, } from '@nestjs/common'; import { SolutionSubmissionService } from '../services/solution-submission.service'; import { Puzzle } from '../entities/puzzle.entity'; @@ -16,6 +16,7 @@ import { AntiCheatService } from '../../anti-cheat/services/anti-cheat.service'; import { SubmitSolutionDto } from '../dto/submit-solution.dto'; import { v4 as uuidv4 } from 'uuid'; import { XpService } from '../../xp/xp.service'; +import { PlayerEventsService } from '../../player-events/player-events.service'; // ────────────────────────────────────────────────────────────────────────────── // Helpers @@ -112,6 +113,12 @@ function makeXpService() { }; } +function makePlayerEventsService() { + return { + emitPlayerEvent: jest.fn().mockResolvedValue(undefined), + }; +} + // ────────────────────────────────────────────────────────────────────────────── // Test Suite // ────────────────────────────────────────────────────────────────────────────── @@ -123,6 +130,7 @@ describe('SolutionSubmissionService', () => { let dataSource: ReturnType; let antiCheatService: ReturnType; let xpService: ReturnType; + let playerEventsService: ReturnType; async function buildService( puzzle: Partial | null = basePuzzle, @@ -134,6 +142,7 @@ describe('SolutionSubmissionService', () => { dataSource = makeDataSource(); antiCheatService = makeAntiCheatService(); xpService = makeXpService(); + playerEventsService = makePlayerEventsService(); const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -143,10 +152,13 @@ describe('SolutionSubmissionService', () => { { provide: DataSource, useValue: dataSource }, { provide: AntiCheatService, useValue: antiCheatService }, { provide: XpService, useValue: xpService }, + { provide: PlayerEventsService, useValue: playerEventsService }, ], }).compile(); service = module.get(SolutionSubmissionService); + + service = module.get(SolutionSubmissionService); } // ──────────────────────────────────────────────────────────────────────────── @@ -186,8 +198,8 @@ describe('SolutionSubmissionService', () => { }); 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) })); + // Time limit is 300s, 30% = 90s, so if we complete in 80s we should get speed_demon and avoid the anti-cheat 25s threshold. + const result = await service.submitSolution('user-1', 'puzzle-uuid-001', makeDto({ sessionStartedAt: makeSessionStart(80) })); expect(result.rewards!.achievements).toContain('speed_demon'); }); @@ -207,6 +219,34 @@ describe('SolutionSubmissionService', () => { }), ); }); + + it('emits player event audit entries for solves', async () => { + await service.submitSolution('user-1', 'puzzle-uuid-001', makeDto()); + + expect(playerEventsService.emitPlayerEvent).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + eventType: 'answer.submitted', + }), + ); + expect(playerEventsService.emitPlayerEvent).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + eventType: 'puzzle.solved', + }), + ); + }); + + it('emits achievement.unlocked for each earned achievement', async () => { + await service.submitSolution('user-1', 'puzzle-uuid-001', makeDto({ hintsUsed: 0 })); + + expect(playerEventsService.emitPlayerEvent).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + eventType: 'achievement.unlocked', + }), + ); + }); }); // ──────────────────────────────────────────────────────────────────────────── @@ -243,6 +283,17 @@ describe('SolutionSubmissionService', () => { ); expect(result.message).toBeTruthy(); }); + + it('emits only answer.submitted for incorrect submissions', async () => { + await service.submitSolution('user-1', 'puzzle-uuid-001', makeDto({ answer: 'wrong_answer' })); + + expect(playerEventsService.emitPlayerEvent).toHaveBeenCalledWith( + expect.objectContaining({ eventType: 'answer.submitted' }), + ); + expect(playerEventsService.emitPlayerEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ eventType: 'puzzle.solved' }), + ); + }); }); // ──────────────────────────────────────────────────────────────────────────── @@ -392,7 +443,7 @@ describe('SolutionSubmissionService', () => { await expect( service.submitSolution('user-1', 'puzzle-uuid-001', makeDto()), - ).rejects.toThrow(TooManyRequestsException); + ).rejects.toThrow(HttpException); }); it('allows submission when under rate limit', async () => { diff --git a/test/hints.service.spec.ts b/test/hints.service.spec.ts index c87d42d..38edb23 100644 --- a/test/hints.service.spec.ts +++ b/test/hints.service.spec.ts @@ -4,6 +4,7 @@ import { HintsService } from '../src/hints/hints.service'; import { Hint } from '../src/hints/entities/hint.entity'; import { HintUsage } from '../src/hints/entities/hint-usage.entity'; import { HintTemplate } from '../src/hints/entities/hint-template.entity'; +import { PlayerEventsService } from '../src/player-events/player-events.service'; describe('HintsService', () => { it('bootstraps service (smoke)', async () => { @@ -18,12 +19,64 @@ describe('HintsService', () => { }), TypeOrmModule.forFeature([Hint, HintUsage, HintTemplate]), ], - providers: [HintsService], + providers: [ + HintsService, + { + provide: PlayerEventsService, + useValue: { emitPlayerEvent: jest.fn().mockResolvedValue(undefined) }, + }, + ], }).compile(); const service = moduleRef.get(HintsService); expect(service).toBeDefined(); }); + + it('emits hint.used event when requesting a hint', async () => { + const playerEventsMock = { emitPlayerEvent: jest.fn().mockResolvedValue(undefined) }; + + const moduleRef = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ + type: 'sqlite', + database: ':memory:', + dropSchema: true, + entities: [Hint, HintUsage, HintTemplate], + synchronize: true, + }), + TypeOrmModule.forFeature([Hint, HintUsage, HintTemplate]), + ], + providers: [ + HintsService, + { provide: PlayerEventsService, useValue: playerEventsMock }, + ], + }).compile(); + + const service = moduleRef.get(HintsService); + + await service.createHint({ + puzzleId: 'puzzle-1', + order: 1, + type: 'general', + content: 'Test hint', + cost: 0, + pointsPenalty: 0, + }); + + await service.requestHint({ + puzzleId: 'puzzle-1', + userId: 'user-1', + attemptNumber: 1, + timeSpent: 10, + }); + + expect(playerEventsMock.emitPlayerEvent).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + eventType: 'hint.used', + }), + ); + }); });