feat: Challenges Page and Challenge Solve Modal
This commit is contained in:
@@ -12,6 +12,7 @@ import { SettingsModule } from './modules/settings/settings.module';
|
||||
import { AdminModule } from './modules/admin/admin.module';
|
||||
import { UploadsModule } from './modules/uploads/uploads.module';
|
||||
import { BlogModule } from './modules/blog/blog.module';
|
||||
import { ChallengesModule } from './modules/challenges/challenges.module';
|
||||
import { FrontendModule } from './frontend/frontend.module';
|
||||
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
||||
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
||||
@@ -33,6 +34,7 @@ import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
||||
AdminModule,
|
||||
UploadsModule,
|
||||
BlogModule,
|
||||
ChallengesModule,
|
||||
FrontendModule,
|
||||
],
|
||||
providers: [
|
||||
|
||||
@@ -27,6 +27,9 @@ export const ERROR_CODES = {
|
||||
CHALLENGE_FILE_LIMIT: 'CHALLENGE_FILE_LIMIT',
|
||||
IMPORT_VALIDATION_FAILED: 'IMPORT_VALIDATION_FAILED',
|
||||
IMPORT_PARTIAL_FAILURE: 'IMPORT_PARTIAL_FAILURE',
|
||||
EVENT_NOT_RUNNING: 'EVENT_NOT_RUNNING',
|
||||
FLAG_INCORRECT: 'FLAG_INCORRECT',
|
||||
CHALLENGE_DISABLED: 'CHALLENGE_DISABLED',
|
||||
INTERNAL: 'INTERNAL',
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||
import { EventStatusService } from '../../common/services/event-status.service';
|
||||
import { ChallengesService } from './challenges.service';
|
||||
import {
|
||||
BoardQuerySchema,
|
||||
BoardResponseDto,
|
||||
ChallengeDetailDto,
|
||||
ChallengeIdParamSchema,
|
||||
SolveResponseDto,
|
||||
SolveSubmitBodySchema,
|
||||
} from './dto/challenges.dto';
|
||||
import type { EventStatePayload } from '../../common/services/event-status.service';
|
||||
|
||||
@ApiTags('challenges')
|
||||
@ApiBearerAuth()
|
||||
@Controller('api/v1/challenges')
|
||||
export class ChallengesController {
|
||||
constructor(
|
||||
private readonly svc: ChallengesService,
|
||||
private readonly statusSvc: EventStatusService,
|
||||
) {}
|
||||
|
||||
private extractUserId(req: Request): string {
|
||||
const user = req.user as { sub: string } | undefined;
|
||||
const userId = user?.sub;
|
||||
if (!userId) {
|
||||
throw new (require('@nestjs/common') as typeof import('@nestjs/common')).UnauthorizedException('Not authenticated');
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
@Get('board')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Authenticated board: categories with challenges, live points, current player solved set. ?include=solvers attaches solver lists to each card.',
|
||||
})
|
||||
async board(
|
||||
@Req() req: Request,
|
||||
@Query(new ZodValidationPipe(BoardQuerySchema)) query: { include?: string },
|
||||
): Promise<BoardResponseDto> {
|
||||
const userId = this.extractUserId(req);
|
||||
const includeTokens = (query.include ?? '')
|
||||
.split(',')
|
||||
.map((t) => t.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
return this.svc.getBoard(userId, { includeSolvers: includeTokens.includes('solvers') });
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
@ApiOperation({
|
||||
summary: 'Authenticated event-window snapshot (REST JSON). Source of truth for the challenges page bootstrap.',
|
||||
})
|
||||
async status(): Promise<EventStatePayload> {
|
||||
return this.statusSvc.getState();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Single challenge detail with solvers list' })
|
||||
async detail(
|
||||
@Req() req: Request,
|
||||
@Param(new ZodValidationPipe(ChallengeIdParamSchema)) params: { id: string },
|
||||
): Promise<ChallengeDetailDto> {
|
||||
const userId = this.extractUserId(req);
|
||||
return this.svc.getDetail(userId, params.id);
|
||||
}
|
||||
|
||||
@Post(':id/solves')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Submit a flag for a challenge; idempotent per (player, challenge)' })
|
||||
async submit(
|
||||
@Req() req: Request,
|
||||
@Param(new ZodValidationPipe(ChallengeIdParamSchema)) params: { id: string },
|
||||
@Body(new ZodValidationPipe(SolveSubmitBodySchema)) body: { flag: string },
|
||||
): Promise<SolveResponseDto> {
|
||||
const userId = this.extractUserId(req);
|
||||
return this.svc.submitFlag(userId, params.id, body.flag);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
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 { CommonModule } from '../../common/common.module';
|
||||
import { ChallengesController } from './challenges.controller';
|
||||
import { ChallengesEventsController } from './events.controller';
|
||||
import { ChallengesService } from './challenges.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ChallengeEntity, CategoryEntity, SolveEntity, UserEntity]),
|
||||
CommonModule,
|
||||
],
|
||||
controllers: [ChallengesController, ChallengesEventsController],
|
||||
providers: [ChallengesService],
|
||||
})
|
||||
export class ChallengesModule {}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const DIFFICULTIES = ['LOW', 'MEDIUM', 'HIGH'] as const;
|
||||
export type Difficulty = (typeof DIFFICULTIES)[number];
|
||||
|
||||
export const SolveSubmitBodySchema = z.object({
|
||||
flag: z.string().min(1).max(4096),
|
||||
});
|
||||
export type SolveSubmitBody = z.infer<typeof SolveSubmitBodySchema>;
|
||||
|
||||
export const BoardQuerySchema = z.object({
|
||||
include: z.string().optional(),
|
||||
});
|
||||
export type BoardQuery = z.infer<typeof BoardQuerySchema>;
|
||||
|
||||
export const ChallengeIdParamSchema = z.object({
|
||||
id: z.string().uuid({ message: 'Invalid challenge id' }),
|
||||
});
|
||||
export type ChallengeIdParam = z.infer<typeof ChallengeIdParamSchema>;
|
||||
|
||||
export interface ChallengeBoardCardDto {
|
||||
id: string;
|
||||
name: string;
|
||||
descriptionMd: string;
|
||||
categoryId: string;
|
||||
categoryAbbreviation: string;
|
||||
categoryIconPath: string;
|
||||
difficulty: Difficulty;
|
||||
livePoints: number;
|
||||
solveCount: number;
|
||||
solvedByMe: boolean;
|
||||
solvers?: SolverRowDto[];
|
||||
}
|
||||
|
||||
export interface CategoryColumnDto {
|
||||
id: string;
|
||||
abbreviation: string;
|
||||
name: string;
|
||||
iconPath: string;
|
||||
cards: ChallengeBoardCardDto[];
|
||||
}
|
||||
|
||||
export interface BoardResponseDto {
|
||||
columns: CategoryColumnDto[];
|
||||
solvedChallengeIds: string[];
|
||||
perChallengeLivePoints: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface SolverRowDto {
|
||||
position: number;
|
||||
playerId: string;
|
||||
playerName: string;
|
||||
solvedAtUtc: string;
|
||||
awardedPoints: number;
|
||||
basePoints: number;
|
||||
rankBonus: number;
|
||||
isFirst: boolean;
|
||||
isSecond: boolean;
|
||||
isThird: boolean;
|
||||
}
|
||||
|
||||
export interface AwardedSolveDto {
|
||||
position: number;
|
||||
basePoints: number;
|
||||
rankBonus: number;
|
||||
awardedPoints: number;
|
||||
awardedAtUtc: string;
|
||||
}
|
||||
|
||||
export interface SolveResponseDto {
|
||||
status: 'solved' | 'already_solved';
|
||||
challenge: ChallengeBoardCardDto;
|
||||
awarded: AwardedSolveDto;
|
||||
solvers: SolverRowDto[];
|
||||
}
|
||||
|
||||
export interface ChallengeDetailDto {
|
||||
challenge: ChallengeBoardCardDto;
|
||||
solvers: SolverRowDto[];
|
||||
}
|
||||
|
||||
export interface SolveEventPayload {
|
||||
challengeId: string;
|
||||
playerId: string;
|
||||
playerName: string;
|
||||
awardedPoints: number;
|
||||
rankBonus: number;
|
||||
awardedAtUtc: string;
|
||||
position: number;
|
||||
livePoints: number;
|
||||
solveCount: number;
|
||||
initialPoints: number;
|
||||
minimumPoints: number;
|
||||
decaySolves: number;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Controller, MessageEvent, Sse } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
Observable,
|
||||
defer,
|
||||
interval,
|
||||
merge,
|
||||
startWith,
|
||||
mergeMap,
|
||||
filter,
|
||||
map,
|
||||
distinctUntilChanged,
|
||||
} from 'rxjs';
|
||||
import { EventStatusService } from '../../common/services/event-status.service';
|
||||
import { SseHubService } from '../../common/services/sse-hub.service';
|
||||
|
||||
@ApiTags('challenges')
|
||||
@ApiBearerAuth()
|
||||
@Controller('api/v1')
|
||||
export class ChallengesEventsController {
|
||||
constructor(
|
||||
private readonly statusSvc: EventStatusService,
|
||||
private readonly hub: SseHubService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Authenticated global event stream.
|
||||
* Emits NestJS MessageEvent objects with the proper `type` field so the
|
||||
* wire format is `event: <type>\ndata: <JSON>\n\n`:
|
||||
* - event: status, payload = { state, server_time_utc, event_start_utc, event_end_utc, seconds_to_start, seconds_to_end }
|
||||
* - event: solve, payload = { challenge_id, player_id, player_name, awarded_points, rank_bonus, awarded_at_utc,
|
||||
* position, live_points, solve_count, initial_points, minimum_points, decay_solves }
|
||||
* Source of truth: every transition of the event window is broadcast by
|
||||
* the admin general service through SseHubService.event$(). Solves are
|
||||
* published by ChallengesService through SseHubService.scoreboard$().
|
||||
* To recover missed transitions while disconnected, the status tick
|
||||
* re-pulls getState() every 60 seconds.
|
||||
*/
|
||||
@Sse('events')
|
||||
@ApiOperation({
|
||||
summary: 'Authenticated global SSE stream: status + solve events',
|
||||
})
|
||||
events(): Observable<MessageEvent> {
|
||||
const flatStatus = (s: any): MessageEvent => ({
|
||||
type: 'status',
|
||||
data: {
|
||||
state: s?.state,
|
||||
server_time_utc: s?.serverNowUtc,
|
||||
event_start_utc: s?.eventStartUtc ?? null,
|
||||
event_end_utc: s?.eventEndUtc ?? null,
|
||||
seconds_to_start: s?.secondsToStart ?? null,
|
||||
seconds_to_end: s?.secondsToEnd ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const statusTick$ = interval(60_000).pipe(
|
||||
startWith(0),
|
||||
mergeMap(() =>
|
||||
defer(() => this.statusSvc.getState().then((s) => flatStatus(s))),
|
||||
),
|
||||
);
|
||||
|
||||
const statusHub$ = this.hub.event$().pipe(
|
||||
map((p) => {
|
||||
if (p && p.topic === 'general') {
|
||||
return defer(() => this.statusSvc.getState().then((s) => flatStatus(s)));
|
||||
}
|
||||
if (p && (p.state || p.eventStartUtc || p.eventEndUtc)) {
|
||||
return defer(async () => flatStatus(p));
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
filter((x): x is Observable<MessageEvent> => !!x),
|
||||
mergeMap((x) => x),
|
||||
);
|
||||
|
||||
const solveFrame = (s: any): MessageEvent => ({
|
||||
type: 'solve',
|
||||
data: {
|
||||
challenge_id: s?.challengeId,
|
||||
player_id: s?.playerId ?? s?.userId,
|
||||
player_name: s?.playerName ?? '',
|
||||
awarded_points: s?.awardedPoints ?? s?.pointsAwarded,
|
||||
rank_bonus: s?.rankBonus,
|
||||
awarded_at_utc: s?.awardedAtUtc ?? s?.solvedAt,
|
||||
position: s?.position,
|
||||
live_points: s?.livePoints,
|
||||
solve_count: s?.solveCount,
|
||||
initial_points: s?.initialPoints,
|
||||
minimum_points: s?.minimumPoints,
|
||||
decay_solves: s?.decaySolves,
|
||||
},
|
||||
});
|
||||
|
||||
const solve$ = this.hub.scoreboard$().pipe(map((s) => solveFrame(s)));
|
||||
|
||||
const statusFrames$ = merge(statusTick$, statusHub$).pipe(
|
||||
distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)),
|
||||
);
|
||||
|
||||
return merge(statusFrames$, solve$);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
|
||||
export type Difficulty = 'LOW' | 'MEDIUM' | 'HIGH';
|
||||
|
||||
export const DIFFICULTY_RANK: Readonly<Record<Difficulty, number>> = {
|
||||
LOW: 0,
|
||||
MEDIUM: 1,
|
||||
HIGH: 2,
|
||||
};
|
||||
|
||||
export function compareDifficulty(a: Difficulty, b: Difficulty): number {
|
||||
return DIFFICULTY_RANK[a] - DIFFICULTY_RANK[b];
|
||||
}
|
||||
|
||||
export function computeLivePoints(
|
||||
initial: number,
|
||||
minimum: number,
|
||||
decaySolves: number,
|
||||
currentSolveCount: number,
|
||||
): number {
|
||||
if (!Number.isFinite(initial) || !Number.isFinite(minimum)) return minimum;
|
||||
const i = Math.max(0, Math.floor(initial));
|
||||
const m = Math.max(0, Math.floor(minimum));
|
||||
const d = Math.max(0, Math.floor(decaySolves));
|
||||
const c = Math.max(0, Math.floor(currentSolveCount));
|
||||
if (i < m) return Math.max(0, m);
|
||||
if (d <= 0) return i;
|
||||
if (c >= d) return m;
|
||||
const span = i - m;
|
||||
const raw = i - span * (c / d);
|
||||
return Math.max(m, Math.round(raw));
|
||||
}
|
||||
|
||||
export function rankBonusForPosition(position: number): number {
|
||||
if (!Number.isInteger(position) || position <= 0) return 0;
|
||||
if (position === 1) return 15;
|
||||
if (position === 2) return 10;
|
||||
if (position === 3) return 5;
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function safeEqualString(a: string, b: string): boolean {
|
||||
const ab = Buffer.from(a, 'utf8');
|
||||
const bb = Buffer.from(b, 'utf8');
|
||||
if (ab.length !== bb.length) {
|
||||
let diff = ab.length ^ bb.length;
|
||||
const max = Math.max(ab.length, bb.length);
|
||||
for (let i = 0; i < max; i += 1) {
|
||||
diff |= (ab[i] ?? 0) ^ (bb[i] ?? 0);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(ab, bb);
|
||||
}
|
||||
Reference in New Issue
Block a user