528 lines
18 KiB
TypeScript
528 lines
18 KiB
TypeScript
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,
|
|
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<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,
|
|
};
|
|
}
|
|
}
|