import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import { v4 as uuid } from 'uuid'; import { ChallengeEntity } from '../../database/entities/challenge.entity'; import { CategoryEntity } from '../../database/entities/category.entity'; import { SolveEntity } from '../../database/entities/solve.entity'; import { UserEntity } from '../../database/entities/user.entity'; import { ApiError } from '../../common/errors/api-error'; import { ERROR_CODES } from '../../common/errors/error-codes'; import { EventStatusService } from '../../common/services/event-status.service'; import { SseHubService } from '../../common/services/sse-hub.service'; import { AwardedSolveDto, BoardResponseDto, CategoryColumnDto, ChallengeBoardCardDto, ChallengeDetailDto, Difficulty, SolveResponseDto, SolverRowDto, } from './dto/challenges.dto'; import { compareDifficulty, computeLivePoints, rankBonusForPosition, safeEqualString, } from './scoring.util'; interface ChallengeRow { id: string; name: string; descriptionMd: string; categoryId: string; difficulty: Difficulty; initialPoints: number; minimumPoints: number; decaySolves: number; enabled: boolean; abbreviation: string; categoryName: string; iconPath: string; } interface SolveRow { id: string; challengeId: string; userId: string; solvedAt: string; pointsAwarded: number; basePoints: number; rankBonus: number; isFirst: boolean; isSecond: boolean; isThird: boolean; username: string; } @Injectable() export class ChallengesService { private readonly logger = new Logger(ChallengesService.name); constructor( @InjectRepository(ChallengeEntity) private readonly challenges: Repository, @InjectRepository(CategoryEntity) private readonly categories: Repository, @InjectRepository(SolveEntity) private readonly solves: Repository, private readonly dataSource: DataSource, private readonly eventStatus: EventStatusService, private readonly hub: SseHubService, ) {} async getBoard(currentUserId: string, opts: { includeSolvers?: boolean } = {}): Promise { const rows = await this.challenges .createQueryBuilder('c') .leftJoin(CategoryEntity, 'cat', 'cat.id = c.category_id') .select('c.id', 'id') .addSelect('c.name', 'name') .addSelect('c.description_md', 'descriptionMd') .addSelect('c.category_id', 'categoryId') .addSelect('c.difficulty', 'difficulty') .addSelect('c.initial_points', 'initialPoints') .addSelect('c.minimum_points', 'minimumPoints') .addSelect('c.decay_solves', 'decaySolves') .addSelect('c.enabled', 'enabled') .addSelect('cat.abbreviation', 'abbreviation') .addSelect('cat.name', 'categoryName') .addSelect('cat.icon_path', 'iconPath') .where('c.enabled = 1') .getRawMany(); if (rows.length === 0) { return { columns: [], solvedChallengeIds: [], perChallengeLivePoints: {} }; } const challengeIds = rows.map((r) => r.id); const solveCountMap = await this.getSolveCounts(challengeIds); const solvedSet = await this.getSolvedChallengeIds(currentUserId, challengeIds); const perChallengeLivePoints: Record = {}; const byCategory = new Map(); for (const row of rows) { const solveCount = solveCountMap.get(row.id) ?? 0; const livePoints = computeLivePoints( row.initialPoints, row.minimumPoints, row.decaySolves, solveCount, ); perChallengeLivePoints[row.id] = livePoints; const card: ChallengeBoardCardDto = { id: row.id, name: row.name, descriptionMd: row.descriptionMd ?? '', categoryId: row.categoryId, categoryAbbreviation: row.abbreviation ?? '', categoryIconPath: row.iconPath ?? '', difficulty: row.difficulty, livePoints, solveCount, solvedByMe: solvedSet.has(row.id), }; const existing = byCategory.get(row.categoryId); if (existing) { existing.cards.push(card); } else { byCategory.set(row.categoryId, { cat: { id: row.categoryId, abbreviation: row.abbreviation ?? '', name: row.categoryName ?? '', iconPath: row.iconPath ?? '', }, cards: [card], }); } } if (opts.includeSolvers) { const mgr = this.dataSource.manager; for (const col of byCategory.values()) { for (const card of col.cards) { const solvers = await this.loadSolvers(mgr, card.id); card.solvers = solvers.map((s, idx) => ({ ...this.toSolverDto(s), position: idx + 1, })); } } } const columns: CategoryColumnDto[] = Array.from(byCategory.values()) .map((entry) => ({ id: entry.cat.id, abbreviation: entry.cat.abbreviation, name: entry.cat.name, iconPath: entry.cat.iconPath, cards: entry.cards.sort((a, b) => { const d = compareDifficulty(a.difficulty, b.difficulty); if (d !== 0) return d; const an = (a.name ?? '').toLowerCase(); const bn = (b.name ?? '').toLowerCase(); if (an < bn) return -1; if (an > bn) return 1; return 0; }), })) .sort((a, b) => { const al = (a.abbreviation ?? '').toLowerCase(); const bl = (b.abbreviation ?? '').toLowerCase(); if (al < bl) return -1; if (al > bl) return 1; return 0; }); return { columns, solvedChallengeIds: Array.from(solvedSet), perChallengeLivePoints, }; } async getDetail(currentUserId: string, challengeId: string): Promise { const challenge = await this.loadChallengeOrThrow(challengeId); const mgr = this.dataSource.manager; const solvers = await this.loadSolvers(mgr, challengeId); const solved = await this.solves .createQueryBuilder('s') .select('1') .where('s.challenge_id = :cid AND s.user_id = :uid', { cid: challengeId, uid: currentUserId }) .getRawOne(); const card = this.buildCard(challenge, solvers.length, !!solved); return { challenge: card, solvers: solvers.map((s, idx) => ({ ...this.toSolverDto(s), position: idx + 1, })), }; } private async loadChallengeOrThrow(challengeId: string): Promise { const row = await this.challenges .createQueryBuilder('c') .leftJoin(CategoryEntity, 'cat', 'cat.id = c.category_id') .select('c.id', 'id') .addSelect('c.name', 'name') .addSelect('c.description_md', 'descriptionMd') .addSelect('c.category_id', 'categoryId') .addSelect('c.difficulty', 'difficulty') .addSelect('c.initial_points', 'initialPoints') .addSelect('c.minimum_points', 'minimumPoints') .addSelect('c.decay_solves', 'decaySolves') .addSelect('c.enabled', 'enabled') .addSelect('cat.abbreviation', 'abbreviation') .addSelect('cat.name', 'categoryName') .addSelect('cat.icon_path', 'iconPath') .where('c.id = :id', { id: challengeId }) .getRawOne(); if (!row || !row.enabled) throw ApiError.notFound('Challenge not found'); return row; } async submitFlag(currentUserId: string, challengeId: string, submittedFlag: string): Promise { return this.dataSource.transaction(async (manager) => { const challengeRepo = manager.getRepository(ChallengeEntity); const solveRepo = manager.getRepository(SolveEntity); const categoryRepo = manager.getRepository(CategoryEntity); const challenge = await challengeRepo .createQueryBuilder('c') .leftJoin(CategoryEntity, 'cat', 'cat.id = c.category_id') .select('c.id', 'id') .addSelect('c.name', 'name') .addSelect('c.description_md', 'descriptionMd') .addSelect('c.category_id', 'categoryId') .addSelect('c.difficulty', 'difficulty') .addSelect('c.initial_points', 'initialPoints') .addSelect('c.minimum_points', 'minimumPoints') .addSelect('c.decay_solves', 'decaySolves') .addSelect('c.flag', 'flag') .addSelect('c.enabled', 'enabled') .addSelect('cat.abbreviation', 'abbreviation') .addSelect('cat.name', 'categoryName') .addSelect('cat.icon_path', 'iconPath') .where('c.id = :id', { id: challengeId }) .getRawOne(); if (!challenge || !challenge.enabled) { throw ApiError.notFound('Challenge not found'); } const state = await this.eventStatus.getState(); if (state.state !== 'running') { throw new ApiError( ERROR_CODES.EVENT_NOT_RUNNING, 'Submissions are only accepted while the event is running', 409, ); } const existing = await solveRepo .createQueryBuilder('s') .leftJoin(UserEntity, 'u', 'u.id = s.user_id') .select('s.id', 'id') .addSelect('s.challenge_id', 'challengeId') .addSelect('s.user_id', 'userId') .addSelect('s.solved_at', 'solvedAt') .addSelect('s.points_awarded', 'pointsAwarded') .addSelect('s.base_points', 'basePoints') .addSelect('s.rank_bonus', 'rankBonus') .addSelect('s.is_first', 'isFirst') .addSelect('s.is_second', 'isSecond') .addSelect('s.is_third', 'isThird') .addSelect('u.username', 'username') .where('s.challenge_id = :cid AND s.user_id = :uid', { cid: challengeId, uid: currentUserId }) .getRawOne(); if (existing) { const solvers = await this.loadSolvers(manager, challengeId); const count = solvers.length; const card = this.buildCard(challenge, count, true); const myIndex = solvers.findIndex((s) => s.userId === currentUserId); const awarded: AwardedSolveDto = { position: myIndex >= 0 ? myIndex + 1 : 0, basePoints: existing.basePoints, rankBonus: existing.rankBonus, awardedPoints: existing.pointsAwarded, awardedAtUtc: existing.solvedAt, }; return { status: 'already_solved', challenge: card, awarded, solvers: solvers.map((s, idx) => ({ ...this.toSolverDto(s), position: idx + 1 })), }; } if (!safeEqualString(challenge.flag ?? '', submittedFlag ?? '')) { throw new ApiError( ERROR_CODES.FLAG_INCORRECT, 'Incorrect flag', 400, ); } const positionRow = await solveRepo .createQueryBuilder('s') .select('COUNT(*)', 'count') .where('s.challenge_id = :cid', { cid: challengeId }) .getRawOne<{ count: string | number }>(); const position = Number(positionRow?.count ?? 0) + 1; const solveCountBefore = position - 1; const basePoints = computeLivePoints( challenge.initialPoints, challenge.minimumPoints, challenge.decaySolves, solveCountBefore, ); const rankBonus = rankBonusForPosition(position); const awardedPoints = basePoints + rankBonus; const awardedAtUtc = new Date().toISOString(); try { await solveRepo.insert({ id: uuid(), challengeId, userId: currentUserId, solvedAt: awardedAtUtc, pointsAwarded: awardedPoints, basePoints, rankBonus, isFirst: position === 1, isSecond: position === 2, isThird: position === 3, }); } catch (err: any) { const code = err && typeof err === 'object' ? (err.code as string) : null; if (code === 'SQLITE_CONSTRAINT_UNIQUE' || code === 'SQLITE_CONSTRAINT') { const solvers = await this.loadSolvers(manager, challengeId); const card = this.buildCard(challenge, solvers.length, true); const mine = solvers.find((s) => s.userId === currentUserId); const myIdx = mine ? solvers.indexOf(mine) : -1; return { status: 'already_solved', challenge: card, awarded: { position: myIdx >= 0 ? myIdx + 1 : position, basePoints: mine?.basePoints ?? basePoints, rankBonus: mine?.rankBonus ?? rankBonus, awardedPoints: mine?.pointsAwarded ?? awardedPoints, awardedAtUtc: mine?.solvedAt ?? awardedAtUtc, }, solvers: solvers.map((s, idx) => ({ ...this.toSolverDto(s), position: idx + 1 })), }; } throw err; } const solvers = await this.loadSolvers(manager, challengeId); const card = this.buildCard(challenge, solvers.length, true); const awarded: AwardedSolveDto = { position, basePoints, rankBonus, awardedPoints, awardedAtUtc, }; this.publishSolveEvent({ challengeId, challengeName: challenge.name, categoryAbbreviation: challenge.abbreviation ?? '', userId: currentUserId, username: solvers.find((s) => s.userId === currentUserId)?.username ?? '', awardedPoints, rankBonus, position, solveCountAfter: solvers.length, livePointsAfter: computeLivePoints( challenge.initialPoints, challenge.minimumPoints, challenge.decaySolves, solvers.length, ), initialPoints: challenge.initialPoints, minimumPoints: challenge.minimumPoints, decaySolves: challenge.decaySolves, solvedAtUtc: awardedAtUtc, manager, }); void categoryRepo; return { status: 'solved', challenge: card, awarded, solvers: solvers.map((s, idx) => ({ ...this.toSolverDto(s), position: idx + 1 })), }; }); } private publishSolveEvent(input: { challengeId: string; challengeName: string; categoryAbbreviation: string; userId: string; username: string; awardedPoints: number; rankBonus: number; position: number; solveCountAfter: number; livePointsAfter: number; initialPoints: number; minimumPoints: number; decaySolves: number; solvedAtUtc: string; manager: any; }): void { try { this.hub.emitScoreboard({ topic: 'solve', challengeId: input.challengeId, challengeName: input.challengeName, categoryAbbreviation: input.categoryAbbreviation, userId: input.userId, playerId: input.userId, playerName: input.username, awardedPoints: input.awardedPoints, rankBonus: input.rankBonus, pointsAwarded: input.awardedPoints, solvedAt: input.solvedAtUtc, awardedAtUtc: input.solvedAtUtc, position: input.position, livePoints: input.livePointsAfter, solveCount: input.solveCountAfter, initialPoints: input.initialPoints, minimumPoints: input.minimumPoints, decaySolves: input.decaySolves, }); } catch (err) { this.logger.warn(`Failed to publish solve SSE: ${(err as Error).message}`); } } private async getSolveCounts(challengeIds: string[]): Promise> { if (challengeIds.length === 0) return new Map(); const rows = await this.solves .createQueryBuilder('s') .select('s.challenge_id', 'challengeId') .addSelect('COUNT(*)', 'count') .where('s.challenge_id IN (:...ids)', { ids: challengeIds }) .groupBy('s.challenge_id') .getRawMany<{ challengeId: string; count: string | number }>(); const map = new Map(); for (const r of rows) map.set(r.challengeId, Number(r.count)); return map; } private async getSolvedChallengeIds(userId: string, challengeIds: string[]): Promise> { if (challengeIds.length === 0) return new Set(); const rows = await this.solves .createQueryBuilder('s') .select('s.challenge_id', 'challengeId') .where('s.user_id = :uid AND s.challenge_id IN (:...ids)', { uid: userId, ids: challengeIds }) .getRawMany<{ challengeId: string }>(); return new Set(rows.map((r) => r.challengeId)); } private async loadSolvers(manager: any, challengeId: string): Promise { const qb = manager .getRepository(SolveEntity) .createQueryBuilder('s') .leftJoin(UserEntity, 'u', 'u.id = s.user_id') .select('s.id', 'id') .addSelect('s.challenge_id', 'challengeId') .addSelect('s.user_id', 'userId') .addSelect('s.solved_at', 'solvedAt') .addSelect('s.points_awarded', 'pointsAwarded') .addSelect('s.base_points', 'basePoints') .addSelect('s.rank_bonus', 'rankBonus') .addSelect('s.is_first', 'isFirst') .addSelect('s.is_second', 'isSecond') .addSelect('s.is_third', 'isThird') .addSelect('u.username', 'username') .where('s.challenge_id = :cid', { cid: challengeId }) .orderBy('s.solved_at', 'ASC') .addOrderBy('s.id', 'ASC'); return (await qb.getRawMany()) as SolveRow[]; } private toSolverDto(s: SolveRow): SolverRowDto { return { position: 0, // filled by caller playerId: s.userId, playerName: s.username ?? '', solvedAtUtc: s.solvedAt, awardedPoints: s.pointsAwarded, basePoints: s.basePoints, rankBonus: s.rankBonus, isFirst: Boolean(s.isFirst), isSecond: Boolean(s.isSecond), isThird: Boolean(s.isThird), }; } private buildCard(row: ChallengeRow, solveCount: number, solvedByMe: boolean): ChallengeBoardCardDto { const livePoints = computeLivePoints(row.initialPoints, row.minimumPoints, row.decaySolves, solveCount); return { id: row.id, name: row.name, descriptionMd: row.descriptionMd ?? '', categoryId: row.categoryId, categoryAbbreviation: row.abbreviation ?? '', categoryIconPath: row.iconPath ?? '', difficulty: row.difficulty, livePoints, solveCount, solvedByMe, }; } }