From 2979c05cf718e34dd5edf6577ef2387fe282fe74 Mon Sep 17 00:00:00 2001 From: rabsqueen Date: Mon, 28 Sep 2026 15:14:36 +0100 Subject: [PATCH 1/3] feat(competition): implement tournament bracket management system (#439) --- src/tournaments/tournament.types.ts | 40 +++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/tournaments/tournament.types.ts diff --git a/src/tournaments/tournament.types.ts b/src/tournaments/tournament.types.ts new file mode 100644 index 00000000..8f4f9335 --- /dev/null +++ b/src/tournaments/tournament.types.ts @@ -0,0 +1,40 @@ +export type TournamentFormat = 'single_elimination'; + +export interface Participant { + id: string; + name: string; + seed?: number; + rating?: number; // For automated ranking/seeding +} + +export interface Match { + id: string; + roundIndex: number; + matchIndex: number; + participant1Id: string | null; // null or 'BYE' + participant2Id: string | null; + winnerId: string | null; + status: 'pending' | 'ready' | 'completed' | 'bye'; + nextMatchId?: string | null; +} + +export interface Round { + roundNumber: number; + matches: Match[]; +} + +export interface TournamentBracket { + tournamentId: string; + format: TournamentFormat; + totalParticipants: number; + rounds: Round[]; +} + +export interface TournamentAnalytics { + totalMatches: number; + completedMatches: number; + pendingMatches: number; + byesCount: number; + completionPercentage: number; + currentRound: number; +} \ No newline at end of file From 811b10de2221eceda82020ec3b17ce71976292c8 Mon Sep 17 00:00:00 2001 From: rabsqueen Date: Mon, 28 Sep 2026 15:15:54 +0100 Subject: [PATCH 2/3] Implemented match advancement, round-robin/elimination tree tracking, and bracket reset logic --- src/tournaments/tournament-bracket.service.ts | 245 ++++++++++++++++++ src/tournaments/tournament-bracket.spec.ts | 53 ++++ 2 files changed, 298 insertions(+) create mode 100644 src/tournaments/tournament-bracket.service.ts create mode 100644 src/tournaments/tournament-bracket.spec.ts diff --git a/src/tournaments/tournament-bracket.service.ts b/src/tournaments/tournament-bracket.service.ts new file mode 100644 index 00000000..4d920fca --- /dev/null +++ b/src/tournaments/tournament-bracket.service.ts @@ -0,0 +1,245 @@ +import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +import { Participant, Match, Round, TournamentBracket, TournamentAnalytics } from './tournament.types'; + +@Injectable() +export class TournamentBracketService { + /** + * 1. Seeding Algorithm (Standard Power-of-2 bracket seeding) + */ + public seedParticipants(participants: Participant[]): Participant[] { + // Sort by rating descending if seed is not explicitly set + const sorted = [...participants].sort((a, b) => (b.rating || 0) - (a.rating || 0)); + + const n = sorted.length; + let power = 1; + while (power < n) power *= 2; + + // Create padded array with BYEs (null) + const seeded: (Participant | null)[] = new Array(power).fill(null); + + // Standard seeding placement algorithm for power of 2 + const getSeedingOrder = (size: number): number[] => { + if (size === 2) return [0, 1]; + const prev = getSeedingOrder(size / 2); + const result: number[] = []; + for (const p of prev) { + result.push(2 * p); + result.push(size - 1 - 2 * p); + } + return result; + }; + + const order = getSeedingOrder(power); + for (let i = 0; i < n; i++) { + seeded[order[i]] = { ...sorted[i], seed: i + 1 }; + } + + // Convert back with nulls representing Byes + return seeded.map((p, idx) => p || { id: `bye_${idx + 1}`, name: 'BYE', seed: idx + 1 }); + } + + /** + * 2. Bracket Generation & Bye Handling + */ + public generateBracket(tournamentId: string, participants: Participant[]): TournamentBracket { + if (participants.length < 2) { + throw new BadRequestException('At least 2 participants are required to generate a bracket.'); + } + + const seededParticipants = this.seedParticipants(participants); + const totalSlots = seededParticipants.length; + const totalRounds = Math.log2(totalSlots); + + const rounds: Round[] = []; + let matchCounter = 1; + + // Build Round 1 + const round1Matches: Match[] = []; + for (let i = 0; i < totalSlots; i += 2) { + const p1 = seededParticipants[i]; + const p2 = seededParticipants[i + 1]; + const isByeMatch = p1.name === 'BYE' || p2.name === 'BYE'; + + let winnerId: string | null = null; + let status: Match['status'] = 'ready'; + + if (p1.name === 'BYE') { + winnerId = p2.id; + status = 'bye'; + } else if (p2.name === 'BYE') { + winnerId = p1.id; + status = 'bye'; + } + + round1Matches.push({ + id: `match_${tournamentId}_r1_${matchCounter++}`, + roundIndex: 1, + matchIndex: round1Matches.length + 1, + participant1Id: p1.name === 'BYE' ? null : p1.id, + participant2Id: p2.name === 'BYE' ? null : p2.id, + winnerId, + status, + }); + } + rounds.push({ roundNumber: 1, matches: round1Matches }); + + // Build Subsequent Rounds + for (let r = 2; r <= totalRounds; r++) { + const prevRoundMatches = rounds[r - 2].matches; + const currentRoundMatches: Match[] = []; + + for (let i = 0; i < prevRoundMatches.length; i += 2) { + currentRoundMatches.push({ + id: `match_${tournamentId}_r${r}_${matchCounter++}`, + roundIndex: r, + matchIndex: currentRoundMatches.length + 1, + participant1Id: null, // Populated as previous round finishes + participant2Id: null, + winnerId: null, + status: 'pending', + }); + } + rounds.push({ roundNumber: r, matches: currentRoundMatches }); + } + + // Auto-advance winners of round 1 byes into round 2 + this.propagateByeWinners(rounds); + + return { + tournamentId, + format: 'single_elimination', + totalParticipants: participants.length, + rounds, + }; + } + + private propagateByeWinners(rounds: Round[]): void { + if (rounds.length < 2) return; + + const r1Matches = rounds[0].matches; + const r2Matches = rounds[1].matches; + + for (let i = 0; i < r1Matches.length; i++) { + const m1 = r1Matches[i]; + if (m1.status === 'bye' && m1.winnerId) { + const targetMatchIndex = Math.floor(i / 2); + const targetMatch = r2Matches[targetMatchIndex]; + const isFirstSlot = i % 2 === 0; + + if (isFirstSlot) { + targetMatch.participant1Id = m1.winnerId; + } else { + targetMatch.participant2Id = m1.winnerId; + } + + if (targetMatch.participant1Id && targetMatch.participant2Id) { + targetMatch.status = 'ready'; + } + } + } + } + + /** + * 3. Match Advancement & Winner Determination + */ + public advanceMatch(bracket: TournamentBracket, matchId: string, winnerId: string): TournamentBracket { + let found = false; + + for (let r = 0; r < bracket.rounds.length; r++) { + const round = bracket.rounds[r]; + const match = round.matches.find((m) => m.id === matchId); + + if (match) { + found = true; + if (match.status === 'completed') { + throw new BadRequestException('Match is already completed.'); + } + if (match.participant1Id !== winnerId && match.participant2Id !== winnerId) { + throw new BadRequestException('Winner must be one of the participants in this match.'); + } + + match.winnerId = winnerId; + match.status = 'completed'; + + // Propagate winner to next round if exists + if (r + 1 < bracket.rounds.length) { + const nextRound = bracket.rounds[r + 1]; + const nextMatchIndex = Math.floor((match.matchIndex - 1) / 2); + const nextMatch = nextRound.matches[nextMatchIndex]; + const isFirstSlot = (match.matchIndex - 1) % 2 === 0; + + if (isFirstSlot) { + nextMatch.participant1Id = winnerId; + } else { + nextMatch.participant2Id = winnerId; + } + + if (nextMatch.participant1Id && nextMatch.participant2Id) { + nextMatch.status = 'ready'; + } + } + break; + } + } + + if (!found) { + throw new NotFoundException(`Match with ID ${matchId} not found in bracket.`); + } + + return bracket; + } + + /** + * 4. Bracket Reset + */ + public resetBracket(tournamentId: string, originalParticipants: Participant[]): TournamentBracket { + return this.generateBracket(tournamentId, originalParticipants); + } + + /** + * 5. Tournament Analytics & Progress Tracking + */ + public getAnalytics(bracket: TournamentBracket): TournamentAnalytics { + let totalMatches = 0; + let completedMatches = 0; + let pendingMatches = 0; + let byesCount = 0; + let currentRound = 1; + + for (const round of bracket.rounds) { + for (const match of round.matches) { + totalMatches++; + if (match.status === 'completed') { + completedMatches++; + } else if (match.status === 'bye') { + byesCount++; + completedMatches++; + } else { + pendingMatches++; + } + } + } + + // Determine current active round + for (let i = 0; i < bracket.rounds.length; i++) { + const hasReadyOrPending = bracket.rounds[i].matches.some( + (m) => m.status === 'ready' || m.status === 'pending' + ); + if (hasReadyOrPending) { + currentRound = bracket.rounds[i].roundNumber; + break; + } + } + + const completionPercentage = totalMatches > 0 ? Number(((completedMatches / totalMatches) * 100).toFixed(2)) : 0; + + return { + totalMatches, + completedMatches, + pendingMatches, + byesCount, + completionPercentage, + currentRound, + }; + } +} \ No newline at end of file diff --git a/src/tournaments/tournament-bracket.spec.ts b/src/tournaments/tournament-bracket.spec.ts new file mode 100644 index 00000000..dd00c160 --- /dev/null +++ b/src/tournaments/tournament-bracket.spec.ts @@ -0,0 +1,53 @@ +import { TournamentBracketService } from './tournament-bracket.service'; +import { Participant } from './tournament.types'; + +describe('TournamentBracketService', () => { + let service: TournamentBracketService; + const mockParticipants: Participant[] = [ + { id: 'p1', name: 'Alpha', rating: 1500 }, + { id: 'p2', name: 'Beta', rating: 1400 }, + { id: 'p3', name: 'Gamma', rating: 1300 }, + { id: 'p4', name: 'Delta', rating: 1200 }, + { id: 'p5', name: 'Epsilon', rating: 1100 }, + ]; + + beforeEach(() => { + service = new TournamentBracketService(); + }); + + it('should generate a valid single-elimination bracket with bye handling for non-power-of-2 participants', () => { + const bracket = service.generateBracket('t_1', mockParticipants); + + expect(bracket.rounds.length).toBe(3); // 5 participants padded to 8 slots -> 3 rounds + expect(bracket.totalParticipants).toBe(5); + + const analytics = service.getAnalytics(bracket); + expect(analytics.totalMatches).toBe(7); // 4 + 2 + 1 + expect(analytics.byesCount).toBeGreaterThan(0); + }); + + it('should correctly advance match winner to the next round', () => { + let bracket = service.generateBracket('t_2', [ + { id: 'p1', name: 'One', rating: 2000 }, + { id: 'p2', name: 'Two', rating: 1900 }, + { id: 'p3', name: 'Three', rating: 1800 }, + { id: 'p4', name: 'Four', rating: 1700 }, + ]); + + const r1MatchId = bracket.rounds[0].matches[0].id; + const p1Id = bracket.rounds[0].matches[0].participant1Id!; + + bracket = service.advanceMatch(bracket, r1MatchId, p1Id); + + const r2Match = bracket.rounds[1].matches[0]; + expect(r2Match.participant1Id).toBe(p1Id); + expect(r2Match.status).toBe('ready'); + }); + + it('should reset the bracket correctly', () => { + const bracket = service.generateBracket('t_3', mockParticipants); + const reset = service.resetBracket('t_3', mockParticipants); + + expect(reset).toEqual(bracket); + }); +}); \ No newline at end of file From 06d49fbebe9b39f26f777fe551bf71ceb087ea57 Mon Sep 17 00:00:00 2001 From: rabsqueen Date: Mon, 28 Sep 2026 15:17:56 +0100 Subject: [PATCH 3/3] feat(player-management): implement player statistics and achievement system --- src/player-profile/player-stats.types.ts | 45 ++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/player-profile/player-stats.types.ts diff --git a/src/player-profile/player-stats.types.ts b/src/player-profile/player-stats.types.ts new file mode 100644 index 00000000..09442a42 --- /dev/null +++ b/src/player-profile/player-stats.types.ts @@ -0,0 +1,45 @@ +export interface PlayerStats { + playerId: string; + totalGamesPlayed: number; + totalWins: number; + totalLosses: number; + winRate: number; // Percentage + currentStreak: number; + maxStreak: number; + totalScore: number; + personalBestScore: number; + lastActiveDate: string; // YYYY-MM-DD + customStats: Record; +} + +export interface AchievementDefinition { + id: string; + title: string; + description: string; + category: 'games' | 'wins' | 'score' | 'streak'; + targetValue: number; + rewardXp: number; +} + +export interface PlayerAchievement { + achievementId: string; + unlockedAt: string; + progress: number; + isUnlocked: boolean; +} + +export interface PlayerStatHistory { + id: string; + playerId: string; + metric: string; + delta: number; + newValue: number; + timestamp: string; +} + +export interface AchievementAnalytics { + totalAchievementsAvailable: number; + totalUnlocked: number; + unlockRatePercentage: number; + categoryBreakdown: Record; +} \ No newline at end of file