AI Implementation feature(865): Scoreboard: Ranking, Matrix, Event Log and Score Graph (#52)

This commit was merged in pull request #52.
This commit is contained in:
2026-07-23 05:13:23 +00:00
parent 49d0930780
commit 7c4843d5cc
26 changed files with 3074 additions and 60 deletions
@@ -5,16 +5,20 @@ 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 { SettingsModule } from '../settings/settings.module';
import { ChallengesController } from './challenges.controller';
import { ChallengesEventsController } from './events.controller';
import { ChallengesService } from './challenges.service';
import { ScoreboardController } from './scoreboard.controller';
import { ScoreboardService } from './scoreboard.service';
@Module({
imports: [
TypeOrmModule.forFeature([ChallengeEntity, CategoryEntity, SolveEntity, UserEntity]),
CommonModule,
SettingsModule,
],
controllers: [ChallengesController, ChallengesEventsController],
providers: [ChallengesService],
controllers: [ChallengesController, ChallengesEventsController, ScoreboardController],
providers: [ChallengesService, ScoreboardService],
})
export class ChallengesModule {}
@@ -0,0 +1,62 @@
import { z } from 'zod';
export const EventLogQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
});
export type EventLogQuery = z.infer<typeof EventLogQuerySchema>;
export interface RankingRowDto {
rank: number;
playerId: string;
playerName: string;
solvedCount: number;
points: number;
colorIndex: number;
}
export interface MatrixChallengeHeaderDto {
id: string;
name: string;
solveCount: number;
categoryAbbreviation: string;
}
export type MatrixCellRank = 1 | 2 | 3 | 'solved' | null;
export interface MatrixViewDto {
players: RankingRowDto[];
challenges: MatrixChallengeHeaderDto[];
cells: Record<string, Record<string, MatrixCellRank>>;
}
export interface EventLogRowDto {
solveId: string;
playerId: string;
playerName: string;
challengeId: string;
challengeName: string;
categoryAbbreviation: string;
position: number;
awardedPoints: number;
awardedAtUtc: string;
}
export interface GraphPointDto {
tUtc: string;
value: number;
}
export interface GraphSeriesDto {
playerId: string;
playerName: string;
colorIndex: number;
points: GraphPointDto[];
}
export interface GraphViewDto {
startUtc: string | null;
endUtc: string | null;
serverNowUtc: string;
state: 'running' | 'countdown' | 'stopped' | 'unconfigured';
series: GraphSeriesDto[];
}
@@ -0,0 +1,48 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { DataSource } from 'typeorm';
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
import { ScoreboardService } from './scoreboard.service';
import {
EventLogQuerySchema,
EventLogRowDto,
GraphViewDto,
MatrixViewDto,
RankingRowDto,
} from './dto/scoreboard.dto';
@ApiTags('scoreboard')
@ApiBearerAuth()
@Controller('api/v1/scoreboard')
export class ScoreboardController {
constructor(
private readonly dataSource: DataSource,
private readonly svc: ScoreboardService,
) {}
@Get('ranking')
@ApiOperation({ summary: 'Authenticated scoreboard ranking (competition ranks)' })
async ranking(): Promise<RankingRowDto[]> {
return this.svc.getRanking(this.dataSource.manager);
}
@Get('matrix')
@ApiOperation({ summary: 'Authenticated scoreboard matrix (players x challenges)' })
async matrix(): Promise<MatrixViewDto> {
return this.svc.getMatrix(this.dataSource.manager);
}
@Get('event-log')
@ApiOperation({ summary: 'Authenticated recent-solves event log' })
async eventLog(
@Query(new ZodValidationPipe(EventLogQuerySchema)) q: { limit?: number },
): Promise<EventLogRowDto[]> {
return this.svc.getEventLog(this.dataSource.manager, q.limit ?? 50);
}
@Get('graph')
@ApiOperation({ summary: 'Authenticated score graph (top 10 players)' })
async graph(): Promise<GraphViewDto> {
return this.svc.getGraph(this.dataSource.manager);
}
}
@@ -0,0 +1,402 @@
import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm';
import { SolveEntity } from '../../database/entities/solve.entity';
import { ChallengeEntity } from '../../database/entities/challenge.entity';
import { CategoryEntity } from '../../database/entities/category.entity';
import { UserEntity } from '../../database/entities/user.entity';
import { SettingsService } from '../settings/settings.module';
import { EventStatusService } from '../../common/services/event-status.service';
import {
computeAwardedPoints,
stablePlayerColorIndex,
} from './scoring.util';
import {
EventLogRowDto,
GraphPointDto,
GraphSeriesDto,
GraphViewDto,
MatrixCellRank,
MatrixChallengeHeaderDto,
MatrixViewDto,
RankingRowDto,
} from './dto/scoreboard.dto';
interface RawSolveRow {
id: string;
challengeId: string;
userId: string;
solvedAt: string;
position?: number;
basePoints?: number;
rankBonus?: number;
pointsAwarded?: number;
username?: string;
challengeName?: string;
categoryAbbreviation?: string;
}
@Injectable()
export class ScoreboardService {
constructor(
private readonly settings: SettingsService,
private readonly eventStatus: EventStatusService,
) {}
async getRanking(manager: EntityManager): Promise<RankingRowDto[]> {
const [rows, users] = await Promise.all([
this.loadSolveRows(manager),
manager
.getRepository(UserEntity)
.createQueryBuilder('u')
.select('u.id', 'id')
.addSelect('u.username', 'username')
.getRawMany<{ id: string; username: string }>(),
]);
return this.buildRanking(rows, users);
}
async getMatrix(manager: EntityManager): Promise<MatrixViewDto> {
const [solveRows, challengeRows, users] = await Promise.all([
this.loadSolveRows(manager),
this.loadChallengeRows(manager),
manager
.getRepository(UserEntity)
.createQueryBuilder('u')
.select('u.id', 'id')
.addSelect('u.username', 'username')
.getRawMany<{ id: string; username: string }>(),
]);
const ranking = this.buildRanking(solveRows, users);
const playerById = new Map(ranking.map((r) => [r.playerId, r]));
const challengeSolveCounts = new Map<string, number>();
const positionByPlayerChallenge = new Map<string, Map<string, MatrixCellRank>>();
const grouped = new Map<string, RawSolveRow[]>();
for (const row of solveRows) {
const arr = grouped.get(row.challengeId) ?? [];
arr.push(row);
grouped.set(row.challengeId, arr);
}
for (const [, list] of grouped) {
list.sort((a, b) => new Date(a.solvedAt).getTime() - new Date(b.solvedAt).getTime());
}
for (const [challengeId, list] of grouped) {
challengeSolveCounts.set(challengeId, list.length);
for (let i = 0; i < list.length; i += 1) {
const position = i + 1;
const row = list[i];
let perPlayer = positionByPlayerChallenge.get(row.userId);
if (!perPlayer) {
perPlayer = new Map();
positionByPlayerChallenge.set(row.userId, perPlayer);
}
const value: MatrixCellRank = position <= 3 ? (position as 1 | 2 | 3) : 'solved';
perPlayer.set(challengeId, value);
}
}
const challenges: MatrixChallengeHeaderDto[] = challengeRows
.map((c) => ({
id: c.id,
name: c.name,
solveCount: challengeSolveCounts.get(c.id) ?? 0,
categoryAbbreviation: c.categoryAbbreviation ?? '',
}))
.sort((a, b) => {
if (a.solveCount !== b.solveCount) return b.solveCount - a.solveCount;
const an = (a.name ?? '').toLowerCase();
const bn = (b.name ?? '').toLowerCase();
if (an < bn) return -1;
if (an > bn) return 1;
return 0;
});
const cells: Record<string, Record<string, MatrixCellRank>> = {};
for (const player of ranking) {
const perPlayer = positionByPlayerChallenge.get(player.playerId);
const row: Record<string, MatrixCellRank> = {};
for (const ch of challenges) {
row[ch.id] = perPlayer?.get(ch.id) ?? null;
}
cells[player.playerId] = row;
}
const players = ranking.map((r) => {
const present = playerById.get(r.playerId);
return present ?? r;
});
return { players, challenges, cells };
}
async getEventLog(manager: EntityManager, limit: number): Promise<EventLogRowDto[]> {
const safeLimit = Math.max(1, Math.min(200, Math.floor(limit)));
const rows = await manager
.getRepository(SolveEntity)
.createQueryBuilder('s')
.leftJoin(UserEntity, 'u', 'u.id = s.user_id')
.leftJoin(ChallengeEntity, 'c', 'c.id = s.challenge_id')
.leftJoin(CategoryEntity, 'cat', 'cat.id = c.category_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('u.username', 'username')
.addSelect('c.name', 'challengeName')
.addSelect('cat.abbreviation', 'categoryAbbreviation')
.orderBy('s.solved_at', 'DESC')
.addOrderBy('s.id', 'DESC')
.limit(safeLimit)
.getRawMany<RawSolveRow>();
const perChallengeLatestByChallenge = new Map<string, RawSolveRow[]>();
for (const r of rows) {
const arr = perChallengeLatestByChallenge.get(r.challengeId) ?? [];
arr.push(r);
perChallengeLatestByChallenge.set(r.challengeId, arr);
}
for (const arr of perChallengeLatestByChallenge.values()) {
arr.sort((a, b) => new Date(a.solvedAt).getTime() - new Date(b.solvedAt).getTime());
}
const positionBySolveId = new Map<string, number>();
for (const arr of perChallengeLatestByChallenge.values()) {
arr.forEach((row, idx) => positionBySolveId.set(row.id, idx + 1));
}
return rows.map((r) => {
const position = positionBySolveId.get(r.id) ?? 0;
return {
solveId: r.id,
playerId: r.userId,
playerName: r.username ?? '',
challengeId: r.challengeId,
challengeName: r.challengeName ?? '',
categoryAbbreviation: r.categoryAbbreviation ?? '',
position,
awardedPoints: Number(r.pointsAwarded ?? 0),
awardedAtUtc: r.solvedAt,
};
});
}
async getGraph(manager: EntityManager): Promise<GraphViewDto> {
const state = await this.eventStatus.getState();
const startUtc = state.eventStartUtc;
const endUtc = state.eventEndUtc;
const serverNowUtc = state.serverNowUtc;
if (!startUtc || !endUtc) {
return {
startUtc: null,
endUtc: null,
serverNowUtc,
state: state.state,
series: [],
};
}
const challengeRows = await this.loadChallengeRows(manager);
const challengeMeta = new Map(challengeRows.map((c) => [c.id, c]));
const rows = await this.loadSolveRows(manager);
const perPlayer: Map<string, GraphSeriesDto> = new Map();
for (const row of rows) {
const ch = challengeMeta.get(row.challengeId);
if (!ch) continue;
const base = computeAwardedPoints(
ch.initialPoints,
ch.minimumPoints,
ch.decaySolves,
row.solveCountBefore ?? 0,
);
let series = perPlayer.get(row.userId);
if (!series) {
series = {
playerId: row.userId,
playerName: row.username ?? '',
colorIndex: stablePlayerColorIndex(row.userId),
points: [],
};
perPlayer.set(row.userId, series);
}
const last = series.points[series.points.length - 1];
const prevValue = last ? last.value : 0;
series.points.push({ tUtc: row.solvedAt, value: prevValue + base });
}
const all = Array.from(perPlayer.values());
all.sort((a, b) => {
const av = a.points[a.points.length - 1]?.value ?? 0;
const bv = b.points[b.points.length - 1]?.value ?? 0;
if (av !== bv) return bv - av;
const al = a.points[0]?.tUtc ?? '';
const bl = b.points[0]?.tUtc ?? '';
if (al < bl) return -1;
if (al > bl) return 1;
return 0;
});
const top = all.slice(0, 10).map((s) => {
const final = s.points[s.points.length - 1]?.value ?? 0;
const head: GraphPointDto = { tUtc: startUtc, value: 0 };
const tail: GraphPointDto = { tUtc: endUtc, value: final };
return { ...s, points: [head, ...s.points, tail] };
});
return {
startUtc,
endUtc,
serverNowUtc,
state: state.state,
series: top,
};
}
private async loadSolveRows(manager: EntityManager): Promise<RawSolveRowWithMeta[]> {
const all = await manager
.getRepository(SolveEntity)
.createQueryBuilder('s')
.leftJoin(UserEntity, 'u', 'u.id = s.user_id')
.leftJoin(ChallengeEntity, 'c', 'c.id = s.challenge_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('u.username', 'username')
.addSelect('c.name', 'challengeName')
.orderBy('s.solved_at', 'ASC')
.addOrderBy('s.id', 'ASC')
.getRawMany<RawSolveRow & { challengeName?: string }>();
const perChallenge = new Map<string, RawSolveRowWithMeta[]>();
for (const row of all) {
const arr = perChallenge.get(row.challengeId) ?? [];
arr.push(row);
perChallenge.set(row.challengeId, arr);
}
const out: RawSolveRowWithMeta[] = [];
for (const [, arr] of perChallenge) {
arr.forEach((row, idx) => {
out.push({ ...row, solveCountBefore: idx, position: idx + 1 });
});
}
return out;
}
private async loadChallengeRows(manager: EntityManager): Promise<ChallengeMetaRow[]> {
return manager
.getRepository(ChallengeEntity)
.createQueryBuilder('c')
.leftJoin(CategoryEntity, 'cat', 'cat.id = c.category_id')
.select('c.id', 'id')
.addSelect('c.name', 'name')
.addSelect('c.initial_points', 'initialPoints')
.addSelect('c.minimum_points', 'minimumPoints')
.addSelect('c.decay_solves', 'decaySolves')
.addSelect('cat.abbreviation', 'categoryAbbreviation')
.where('c.enabled = 1')
.getRawMany<ChallengeMetaRow>();
}
private buildRanking(
rows: RawSolveRowWithMeta[],
users: { id: string; username: string }[] = [],
): RankingRowDto[] {
interface Accum {
playerId: string;
playerName: string;
points: number;
solved: Set<string>;
earliest: string;
}
const acc = new Map<string, Accum>();
for (const row of rows) {
let entry = acc.get(row.userId);
if (!entry) {
entry = {
playerId: row.userId,
playerName: row.username ?? '',
points: 0,
solved: new Set<string>(),
earliest: row.solvedAt,
};
acc.set(row.userId, entry);
}
if (new Date(row.solvedAt).getTime() < new Date(entry.earliest).getTime()) {
entry.earliest = row.solvedAt;
}
entry.solved.add(row.challengeId);
entry.playerName = entry.playerName || row.username || '';
entry.points += Number(row.pointsAwarded ?? 0);
}
for (const u of users) {
if (!acc.has(u.id)) {
acc.set(u.id, {
playerId: u.id,
playerName: u.username,
points: 0,
solved: new Set<string>(),
earliest: '',
});
}
}
const list: RankingRowDto[] = [];
for (const [, entry] of acc) {
list.push({
rank: 0,
playerId: entry.playerId,
playerName: entry.playerName,
solvedCount: entry.solved.size,
points: entry.points,
colorIndex: stablePlayerColorIndex(entry.playerId),
});
}
list.sort((a, b) => {
if (a.points !== b.points) return b.points - a.points;
const aE = acc.get(a.playerId)?.earliest ?? '';
const bE = acc.get(b.playerId)?.earliest ?? '';
if (aE && bE) {
if (aE < bE) return -1;
if (aE > bE) return 1;
return 0;
}
if (aE) return -1;
if (bE) return 1;
return 0;
});
let displayedRank = 0;
let lastPoints = Number.NaN;
list.forEach((row, idx) => {
if (row.points !== lastPoints) {
displayedRank = idx + 1;
lastPoints = row.points;
}
row.rank = displayedRank;
});
return list;
}
}
interface RawSolveRowWithMeta extends RawSolveRow {
challengeName?: string;
solveCountBefore?: number;
position?: number;
}
interface ChallengeMetaRow {
id: string;
name: string;
initialPoints: number;
minimumPoints: number;
decaySolves: number;
categoryAbbreviation?: string;
}
@@ -39,6 +39,41 @@ export function rankBonusForPosition(position: number): number {
return 0;
}
export function computeAwardedPoints(
initial: number,
minimum: number,
decaySolves: number,
solveCountBefore: number,
): number {
const position = solveCountBefore + 1;
const base = computeLivePoints(initial, minimum, decaySolves, solveCountBefore);
return base + rankBonusForPosition(position);
}
export const PLAYER_COLOR_PALETTE: ReadonlyArray<string> = [
'#f59e0b',
'#3b82f6',
'#10b981',
'#ef4444',
'#8b5cf6',
'#ec4899',
'#14b8a6',
'#f97316',
'#6366f1',
'#84cc16',
];
export function stablePlayerColorIndex(playerId: string): number {
if (!playerId) return 0;
let hash = 0x811c9dc5;
for (let i = 0; i < playerId.length; i += 1) {
hash ^= playerId.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
const n = PLAYER_COLOR_PALETTE.length;
return ((hash % n) + n) % n;
}
export function safeEqualString(a: string, b: string): boolean {
const ab = Buffer.from(a, 'utf8');
const bb = Buffer.from(b, 'utf8');