AI Implementation feature(863): Challenges Page and Challenge Solve Modal (#45)

This commit was merged in pull request #45.
This commit is contained in:
2026-07-23 00:13:59 +00:00
parent dcda3dfd4d
commit 0bd1d9472a
40 changed files with 4710 additions and 130 deletions
@@ -0,0 +1,521 @@
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<ChallengeEntity>,
@InjectRepository(CategoryEntity)
private readonly categories: Repository<CategoryEntity>,
@InjectRepository(SolveEntity)
private readonly solves: Repository<SolveEntity>,
private readonly dataSource: DataSource,
private readonly eventStatus: EventStatusService,
private readonly hub: SseHubService,
) {}
async getBoard(currentUserId: string, opts: { includeSolvers?: boolean } = {}): Promise<BoardResponseDto> {
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<ChallengeRow>();
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<string, number> = {};
const byCategory = new Map<string, { cat: { id: string; abbreviation: string; name: string; iconPath: string }; cards: ChallengeBoardCardDto[] }>();
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<ChallengeDetailDto> {
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<ChallengeRow> {
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<ChallengeRow>();
if (!row || !row.enabled) throw ApiError.notFound('Challenge not found');
return row;
}
async submitFlag(currentUserId: string, challengeId: string, submittedFlag: string): Promise<SolveResponseDto> {
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<ChallengeRow & { flag: string }>();
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<SolveRow>();
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,
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;
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,
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<Map<string, number>> {
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<string, number>();
for (const r of rows) map.set(r.challengeId, Number(r.count));
return map;
}
private async getSolvedChallengeIds(userId: string, challengeIds: string[]): Promise<Set<string>> {
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<SolveRow[]> {
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,
};
}
}