AI Implementation feature(863): Challenges Page and Challenge Solve Modal (#45)
This commit was merged in pull request #45.
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user