From 45c9c71710056d6175deae6fc5fb8cc99e8ff802 Mon Sep 17 00:00:00 2001 From: Brymo14 <329679870+Brymo14@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:00:15 +0100 Subject: [PATCH] feat(quests): add server-timed time attack mode --- .../AddTimeAttackMode1792512000000.ts | 23 ++++++++++++++++ .../quest-chain-leaderboard.controller.ts | 12 +++++++++ .../quest-chain-progress.controller.ts | 4 ++- .../user-quest-chain-progress.entity.ts | 7 +++++ .../quest-chain-leaderboard.service.ts | 22 +++++++++++++++ .../quest-chain-progression.service.ts | 27 +++++++++++++++++-- 6 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 src/migrations/AddTimeAttackMode1792512000000.ts diff --git a/src/migrations/AddTimeAttackMode1792512000000.ts b/src/migrations/AddTimeAttackMode1792512000000.ts new file mode 100644 index 00000000..b210be97 --- /dev/null +++ b/src/migrations/AddTimeAttackMode1792512000000.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTimeAttackMode1792512000000 implements MigrationInterface { + name = 'AddTimeAttackMode1792512000000'; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_quest_chain_progress" ADD COLUMN "mode" varchar(20) NOT NULL DEFAULT 'standard'`, + ); + await queryRunner.query( + `ALTER TABLE "user_quest_chain_progress" ADD COLUMN "deadlineAt" TIMESTAMP WITH TIME ZONE`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_quest_chain_progress" DROP COLUMN "deadlineAt"`, + ); + await queryRunner.query( + `ALTER TABLE "user_quest_chain_progress" DROP COLUMN "mode"`, + ); + } +} diff --git a/src/quests/controllers/quest-chain-leaderboard.controller.ts b/src/quests/controllers/quest-chain-leaderboard.controller.ts index 49709826..26a6f39e 100644 --- a/src/quests/controllers/quest-chain-leaderboard.controller.ts +++ b/src/quests/controllers/quest-chain-leaderboard.controller.ts @@ -36,6 +36,18 @@ export class QuestChainLeaderboardController { return await this.leaderboardService.getSpeedRunLeaderboard(chainId, limit); } + @Get(':id/leaderboard/time-attack') + @ApiOperation({ summary: 'Get verified time attack completions for a quest chain' }) + @ApiParam({ name: 'id', description: 'Quest chain ID' }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiResponse({ status: 200, description: 'Time attack leaderboard retrieved successfully' }) + async getTimeAttackLeaderboard( + @Param('id') chainId: string, + @Query('limit', new ValidationPipe({ transform: true })) limit = 10, + ) { + return this.leaderboardService.getTimeAttackLeaderboard(chainId, limit); + } + @Get(':id/leaderboard/score') @ApiOperation({ summary: 'Get score leaderboard for a quest chain' }) @ApiParam({ name: 'id', description: 'Quest chain ID' }) diff --git a/src/quests/controllers/quest-chain-progress.controller.ts b/src/quests/controllers/quest-chain-progress.controller.ts index 54c3b1e1..35a5aa13 100644 --- a/src/quests/controllers/quest-chain-progress.controller.ts +++ b/src/quests/controllers/quest-chain-progress.controller.ts @@ -38,8 +38,10 @@ export class QuestChainProgressController { async startChain( @Param('id') chainId: string, @Body('userId') userId: string, + @Body('mode') mode: 'standard' | 'time_attack' = 'standard', + @Body('timeLimitSeconds') timeLimitSeconds = 300, ): Promise { - return this.progressionService.startChain(userId, chainId); + return this.progressionService.startChain(userId, chainId, mode, timeLimitSeconds); } @Get(':id/progress') diff --git a/src/quests/entities/user-quest-chain-progress.entity.ts b/src/quests/entities/user-quest-chain-progress.entity.ts index bb73fd6f..44898ccc 100644 --- a/src/quests/entities/user-quest-chain-progress.entity.ts +++ b/src/quests/entities/user-quest-chain-progress.entity.ts @@ -64,6 +64,13 @@ export class UserQuestChainProgress { @Column({ type: 'int', default: 0 }) totalHintsUsed: number; + /** The server-owned timed variant; normal play remains unchanged. */ + @Column({ type: 'varchar', length: 20, default: 'standard' }) + mode: 'standard' | 'time_attack'; + + @Column({ type: 'timestamp with time zone', nullable: true }) + deadlineAt?: Date; + @Column({ type: 'timestamp with time zone', nullable: true }) startedAt?: Date; diff --git a/src/quests/services/quest-chain-leaderboard.service.ts b/src/quests/services/quest-chain-leaderboard.service.ts index fe231d3c..53951686 100644 --- a/src/quests/services/quest-chain-leaderboard.service.ts +++ b/src/quests/services/quest-chain-leaderboard.service.ts @@ -48,6 +48,28 @@ export class QuestChainLeaderboardService { })); } + async getTimeAttackLeaderboard(chainId: string, limit = 10): Promise { + const safeLimit = Math.min(100, Math.max(1, Math.trunc(limit) || 10)); + const rows = await this.userProgressRepository + .createQueryBuilder('progress') + .select(['progress.userId', 'progress.totalTime', 'progress.completedAt']) + .where('progress.questChainId = :chainId', { chainId }) + .andWhere('progress.status = :status', { status: 'completed' }) + .andWhere('progress.mode = :mode', { mode: 'time_attack' }) + .orderBy('progress.totalTime', 'ASC') + .limit(safeLimit) + .getRawMany(); + + return rows.map((row, index) => ({ + rank: index + 1, + userId: row.progress_userId, + username: `User ${row.progress_userId}`, + value: Number(row.progress_totalTime), + completedAt: row.progress_completedAt, + chainName: undefined, + })); + } + async getScoreLeaderboard( chainId: string, limit = 10, diff --git a/src/quests/services/quest-chain-progression.service.ts b/src/quests/services/quest-chain-progression.service.ts index 3cd745ff..44674891 100644 --- a/src/quests/services/quest-chain-progression.service.ts +++ b/src/quests/services/quest-chain-progression.service.ts @@ -28,7 +28,15 @@ export class QuestChainProgressionService { async startChain( userId: string, chainId: string, + mode: 'standard' | 'time_attack' = 'standard', + timeLimitSeconds = 300, ): Promise { + if (mode !== 'standard' && mode !== 'time_attack') { + throw new BadRequestException('Unsupported quest mode'); + } + if (!Number.isInteger(timeLimitSeconds) || timeLimitSeconds < 30 || timeLimitSeconds > 3600) { + throw new BadRequestException('Time attack limit must be between 30 and 3600 seconds'); + } // Check if user already has progress for this chain const existingProgress = await this.userProgressRepository.findOne({ where: { userId, questChainId: chainId }, @@ -50,7 +58,11 @@ export class QuestChainProgressionService { totalScore: 0, totalTime: 0, totalHintsUsed: 0, + mode, startedAt: new Date(), + deadlineAt: mode === 'time_attack' + ? new Date(Date.now() + timeLimitSeconds * 1000) + : null, lastPlayedAt: new Date(), }); @@ -140,6 +152,11 @@ export class QuestChainProgressionService { ); } + const now = new Date(); + if (progress.mode === 'time_attack' && progress.deadlineAt && now >= progress.deadlineAt) { + throw new BadRequestException('Time attack has expired'); + } + try { // Update progress progress.completedPuzzleIds = [...progress.completedPuzzleIds, puzzleId]; @@ -148,7 +165,9 @@ export class QuestChainProgressionService { chainPuzzle.sequenceOrder, ); progress.totalScore += completionData.score; - progress.totalTime += completionData.timeTaken; + progress.totalTime = progress.mode === 'time_attack' && progress.startedAt + ? Math.max(0, Math.ceil((now.getTime() - progress.startedAt.getTime()) / 1000)) + : progress.totalTime + completionData.timeTaken; progress.totalHintsUsed += completionData.hintsUsed; progress.lastPlayedAt = new Date(); @@ -185,7 +204,11 @@ export class QuestChainProgressionService { if (allPuzzlesCompleted) { progress.status = 'completed'; - progress.completedAt = new Date(); + progress.completedAt = now; + if (progress.mode === 'time_attack' && progress.deadlineAt) { + const remainingSeconds = Math.max(0, Math.floor((progress.deadlineAt.getTime() - now.getTime()) / 1000)); + progress.totalScore += Math.min(remainingSeconds, Math.floor(progress.totalScore * 0.2)); + } // Award completion rewards await this.awardCompletionRewards(userId, chainId);