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,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);
}