From 16dda53b0ac70454964c7f33b17fa774883b144c Mon Sep 17 00:00:00 2001 From: cybermax4200 Date: Sat, 27 Jun 2026 14:51:40 +0100 Subject: [PATCH] feat: leaderboard-prize-distribution --- BackendAcademy/src/rewards/index.ts | 6 + .../rewards/interfaces/rewards.interfaces.ts | 73 +++++++ .../src/rewards/rewards.constants.ts | 47 +++++ .../src/rewards/rewards.controller.ts | 89 +++++++++ .../src/rewards/rewards.service.spec.ts | 189 ++++++++++++++++++ BackendAcademy/src/rewards/rewards.service.ts | 178 +++++++++++++++++ 6 files changed, 582 insertions(+) diff --git a/BackendAcademy/src/rewards/index.ts b/BackendAcademy/src/rewards/index.ts index 1efd5c702..2e05b9d2a 100644 --- a/BackendAcademy/src/rewards/index.ts +++ b/BackendAcademy/src/rewards/index.ts @@ -11,4 +11,10 @@ export type { LevelThreshold, UserProgressionResponse, ThresholdsResponse, + LeaderboardEntry, + LeaderboardResponse, + UserLeaderboardPosition, + PrizeDistribution, + PrizePoolResponse, + CreatePrizePoolRequest, } from './interfaces/rewards.interfaces'; diff --git a/BackendAcademy/src/rewards/interfaces/rewards.interfaces.ts b/BackendAcademy/src/rewards/interfaces/rewards.interfaces.ts index 3828a015f..6b1428de6 100644 --- a/BackendAcademy/src/rewards/interfaces/rewards.interfaces.ts +++ b/BackendAcademy/src/rewards/interfaces/rewards.interfaces.ts @@ -30,3 +30,76 @@ export interface UserProgressionResponse { export interface ThresholdsResponse { thresholds: LevelThreshold[]; } + +// --------------------------------------------------------------------------- +// Leaderboard types +// --------------------------------------------------------------------------- + +/** + * A single entry on the leaderboard. + */ +export interface LeaderboardEntry { + /** Position (1-based) */ + rank: number; + userId: string; + xp: number; + level: number; + /** Human-readable level title */ + title: string; +} + +/** + * Response shape for GET /rewards/leaderboard + */ +export interface LeaderboardResponse { + leaderboard: LeaderboardEntry[]; + /** Total number of participants in the XP system */ + totalParticipants: number; +} + +/** + * Response shape for GET /rewards/leaderboard/:userId + */ +export interface UserLeaderboardPosition { + userId: string; + /** Current 1-based rank */ + rank: number; + xp: number; + level: number; + title: string; + totalParticipants: number; +} + +// --------------------------------------------------------------------------- +// Prize pool types +// --------------------------------------------------------------------------- + +/** + * A single prize distribution to a user. + */ +export interface PrizeDistribution { + rank: number; + userId: string; + amount: number; + distributedAt: Date; +} + +/** + * Response shape for prize pool endpoints. + */ +export interface PrizePoolResponse { + id: string; + totalAmount: number; + currency: string; + distributedAt: Date | null; + createdAt: Date; + distribution: PrizeDistribution[]; +} + +/** + * Body shape for POST /rewards/prize-pool + */ +export interface CreatePrizePoolRequest { + totalAmount: number; + currency?: string; +} diff --git a/BackendAcademy/src/rewards/rewards.constants.ts b/BackendAcademy/src/rewards/rewards.constants.ts index 7768beba7..190d36f34 100644 --- a/BackendAcademy/src/rewards/rewards.constants.ts +++ b/BackendAcademy/src/rewards/rewards.constants.ts @@ -52,3 +52,50 @@ export function xpToNextLevel(xp: number, level: number): number { if (level >= MAX_LEVEL) return 0; return xpThresholdForLevel(level + 1) - xp; } + +// --------------------------------------------------------------------------- +// Leaderboard configuration +// --------------------------------------------------------------------------- + +/** + * Default number of entries returned by the leaderboard endpoint + * when no explicit ?topN query parameter is provided. + */ +export const LEADERBOARD_DEFAULT_TOP_N = 100; + +// --------------------------------------------------------------------------- +// Prize pool configuration +// --------------------------------------------------------------------------- + +/** + * Default currency symbol used for prize pools. + */ +export const PRIZE_POOL_DEFAULT_CURRENCY = 'XLM'; + +/** + * Default prize pool amount (in whole currency units) when a pool + * is auto-created during distribution and no explicit amount was set. + */ +export const PRIZE_POOL_DEFAULT_AMOUNT = 1000; + +/** + * Distribution schedule for the prize pool. + * + * Each entry defines what percentage of the total pool a user at + * a given rank receives. Ranks not listed receive no payout. + * + * Percentages should sum to 100 (or less — unallocated remainder + * stays in the pool for the next cycle). + */ +export const PRIZE_DISTRIBUTION_PERCENTAGES: { rank: number; percentage: number }[] = [ + { rank: 1, percentage: 30 }, + { rank: 2, percentage: 20 }, + { rank: 3, percentage: 15 }, + { rank: 4, percentage: 7.5 }, + { rank: 5, percentage: 7.5 }, + { rank: 6, percentage: 4 }, + { rank: 7, percentage: 4 }, + { rank: 8, percentage: 4 }, + { rank: 9, percentage: 4 }, + { rank: 10, percentage: 4 }, +]; diff --git a/BackendAcademy/src/rewards/rewards.controller.ts b/BackendAcademy/src/rewards/rewards.controller.ts index 124bb8247..451dcf7bd 100644 --- a/BackendAcademy/src/rewards/rewards.controller.ts +++ b/BackendAcademy/src/rewards/rewards.controller.ts @@ -1,16 +1,24 @@ import { Controller, Get, + Post, Param, + Query, + Body, ParseIntPipe, HttpCode, HttpStatus, + NotFoundException, } from '@nestjs/common'; import { RewardsService } from './rewards.service'; import type { UserProgressionResponse, ThresholdsResponse, LevelThreshold, + LeaderboardResponse, + UserLeaderboardPosition, + PrizePoolResponse, + CreatePrizePoolRequest, } from './interfaces/rewards.interfaces'; /** @@ -82,4 +90,85 @@ export class RewardsController { ): UserProgressionResponse { return this.rewardsService.getUserProgression(userId); } + + /** + * Returns the top N users on the leaderboard, sorted by XP descending. + * + * @param topN Number of entries (default 100, query parameter) + * + * @example + * GET /rewards/leaderboard?topN=10 + * → { leaderboard: [{ rank: 1, userId: "…", xp: 5000, level: 8, title: "Apprentice 3" }, …] } + */ + @Get('leaderboard') + @HttpCode(HttpStatus.OK) + getLeaderboard( + @Query('topN', ParseIntPipe) topN: number, + ): LeaderboardResponse { + return this.rewardsService.getLeaderboard(topN); + } + + /** + * Returns a user's current rank, XP, level, and title. + * + * @example + * GET /rewards/leaderboard/abc-123 + * → { userId: "abc-123", rank: 7, xp: 2000, level: 5, title: "Apprentice", totalParticipants: 42 } + */ + @Get('leaderboard/:userId') + @HttpCode(HttpStatus.OK) + getUserLeaderboardPosition( + @Param('userId') userId: string, + ): UserLeaderboardPosition { + return this.rewardsService.getUserLeaderboardPosition(userId); + } + + /** + * Returns the current (most recent) prize pool, or 404 if none exist. + * + * @example + * GET /rewards/prize-pool + * → { id: "prize_…", totalAmount: 1000, currency: "XLM", distributedAt: null, … } + */ + @Get('prize-pool') + @HttpCode(HttpStatus.OK) + getPrizePool(): PrizePoolResponse { + const pool = this.rewardsService.getPrizePool(); + if (!pool) { + throw new NotFoundException('No prize pool has been created yet.'); + } + return pool; + } + + /** + * Creates a new prize pool with the given amount and optional currency. + * + * @example + * POST /rewards/prize-pool + * { "totalAmount": 5000, "currency": "XLM" } + * → { id: "prize_…", totalAmount: 5000, … } + */ + @Post('prize-pool') + @HttpCode(HttpStatus.CREATED) + createPrizePool( + @Body() body: CreatePrizePoolRequest, + ): PrizePoolResponse { + return this.rewardsService.createPrizePool( + body.totalAmount, + body.currency, + ); + } + + /** + * Distributes the current prize pool to the top 10 leaderboard members. + * + * @example + * POST /rewards/prize-pool/distribute + * → { id: "prize_…", totalAmount: 1000, …, distributedAt: "…", distribution: […] } + */ + @Post('prize-pool/distribute') + @HttpCode(HttpStatus.OK) + distributePrizes(): PrizePoolResponse { + return this.rewardsService.distributePrizes(); + } } diff --git a/BackendAcademy/src/rewards/rewards.service.spec.ts b/BackendAcademy/src/rewards/rewards.service.spec.ts index 6c04d39ca..165c363c6 100644 --- a/BackendAcademy/src/rewards/rewards.service.spec.ts +++ b/BackendAcademy/src/rewards/rewards.service.spec.ts @@ -6,6 +6,8 @@ import { levelForXp, xpThresholdForLevel, xpToNextLevel, + PRIZE_POOL_DEFAULT_CURRENCY, + PRIZE_DISTRIBUTION_PERCENTAGES, } from './rewards.constants'; // --------------------------------------------------------------------------- @@ -230,4 +232,191 @@ describe('RewardsService', () => { expect(prog.xp).toBe(50); }); }); + + // ---- Leaderboard ---- + + describe('getLeaderboard()', () => { + const USERS = ['lead-alice', 'lead-bob', 'lead-charlie']; + + beforeEach(() => { + // Reset users and give them distinct XP values + for (const u of USERS) service.resetXp(u); + service.addXp(USERS[0], 500); // alice: 500 + service.addXp(USERS[1], 900); // bob: 900 ← highest + service.addXp(USERS[2], 200); // charlie: 200 + }); + + it('returns entries sorted by XP descending', () => { + const { leaderboard } = service.getLeaderboard(10); + expect(leaderboard[0].userId).toBe(USERS[1]); // bob first + expect(leaderboard[1].userId).toBe(USERS[0]); // alice second + expect(leaderboard[2].userId).toBe(USERS[2]); // charlie third + }); + + it('respects the topN parameter', () => { + const { leaderboard } = service.getLeaderboard(2); + expect(leaderboard).toHaveLength(2); + }); + + it('reports totalParticipants correctly', () => { + const { totalParticipants } = service.getLeaderboard(10); + expect(totalParticipants).toBeGreaterThanOrEqual(USERS.length); + }); + + it('assigns increasing rank numbers', () => { + const { leaderboard } = service.getLeaderboard(10); + leaderboard.forEach((entry, i) => { + expect(entry.rank).toBe(i + 1); + }); + }); + + it('each entry has a non-empty title', () => { + const { leaderboard } = service.getLeaderboard(10); + for (const entry of leaderboard) { + expect(typeof entry.title).toBe('string'); + expect(entry.title.length).toBeGreaterThan(0); + } + }); + }); + + describe('getUserLeaderboardPosition(userId)', () => { + const USERS = ['rank-alice', 'rank-bob', 'rank-charlie']; + + beforeEach(() => { + // Reset users from all suites so ranks are predictable + const allKnown = [ + ...USERS, + 'test-user-abc', + 'brand-new-user', + 'u', + 'lead-alice', + 'lead-bob', + 'lead-charlie', + ]; + for (const u of allKnown) service.resetXp(u); + service.addXp(USERS[0], 100); // alice: 100 + service.addXp(USERS[1], 700); // bob: 700 ← highest + service.addXp(USERS[2], 400); // charlie: 400 + }); + + it('returns rank 1 for the top user', () => { + const pos = service.getUserLeaderboardPosition(USERS[1]); + expect(pos.rank).toBe(1); + }); + + it('returns correct rank for a middle user', () => { + const pos = service.getUserLeaderboardPosition(USERS[2]); + expect(pos.rank).toBe(2); + }); + + it('returns correct rank for the last user', () => { + const pos = service.getUserLeaderboardPosition(USERS[0]); + expect(pos.rank).toBe(3); + }); + + it('throws NotFoundException for unknown user', () => { + expect(() => + service.getUserLeaderboardPosition('ghost-user'), + ).toThrow(NotFoundException); + }); + + it('includes totalParticipants count', () => { + const pos = service.getUserLeaderboardPosition(USERS[1]); + expect(pos.totalParticipants).toBeGreaterThanOrEqual(USERS.length); + }); + }); + + // ---- Prize pool ---- + + describe('getPrizePool() / createPrizePool()', () => { + it('getPrizePool returns null when no pool exists', () => { + expect(service.getPrizePool()).toBeNull(); + }); + + it('createPrizePool creates a pool with correct values', () => { + const pool = service.createPrizePool(5000, 'XLM'); + expect(pool.totalAmount).toBe(5000); + expect(pool.currency).toBe('XLM'); + expect(pool.distributedAt).toBeNull(); + expect(pool.distribution).toEqual([]); + expect(pool.id).toMatch(/^prize_/); + }); + + it('createPrizePool uses default currency when omitted', () => { + const pool = service.createPrizePool(100); + expect(pool.currency).toBe(PRIZE_POOL_DEFAULT_CURRENCY); + }); + + it('createPrizePool throws on non-positive amount', () => { + expect(() => service.createPrizePool(0)).toThrow(); + expect(() => service.createPrizePool(-10)).toThrow(); + }); + + it('getPrizePool returns the latest pool', () => { + service.createPrizePool(100); + const second = service.createPrizePool(200); + const latest = service.getPrizePool(); + expect(latest!.id).toBe(second.id); + expect(latest!.totalAmount).toBe(200); + }); + }); + + describe('distributePrizes()', () => { + const USERS = ['dist-alice', 'dist-bob', 'dist-charlie']; + + beforeEach(() => { + for (const u of USERS) service.resetXp(u); + service.addXp(USERS[0], 100); + service.addXp(USERS[1], 200); + service.addXp(USERS[2], 300); + }); + + it('auto-creates a pool if none exists', () => { + const result = service.distributePrizes(); + expect(result.totalAmount).toBeGreaterThan(0); + expect(result.distributedAt).toBeInstanceOf(Date); + }); + + it('distributes prizes to top 10 leaderboard members', () => { + const result = service.distributePrizes(); + expect(result.distribution.length).toBeGreaterThan(0); + expect(result.distribution.length).toBeLessThanOrEqual(10); + }); + + it('top rank receives the largest amount', () => { + const result = service.distributePrizes(); + const amounts = result.distribution.map((d) => d.amount); + for (let i = 1; i < amounts.length; i++) { + expect(amounts[i - 1]).toBeGreaterThanOrEqual(amounts[i]); + } + }); + + it('distribution amounts use the configured percentages', () => { + const result = service.distributePrizes(); + for (const dist of result.distribution) { + const config = PRIZE_DISTRIBUTION_PERCENTAGES.find( + (c) => c.rank === dist.rank, + ); + expect(config).toBeDefined(); + if (config) { + const expected = Math.floor( + (result.totalAmount * config.percentage) / 100, + ); + expect(dist.amount).toBe(expected); + } + } + }); + + it('marks the pool as distributed', () => { + const result = service.distributePrizes(); + expect(result.distributedAt).toBeInstanceOf(Date); + }); + + it('is idempotent — second call returns same result', () => { + const first = service.distributePrizes(); + const second = service.distributePrizes(); + expect(second.id).toBe(first.id); + expect(second.distributedAt).toEqual(first.distributedAt); + }); + }); }); diff --git a/BackendAcademy/src/rewards/rewards.service.ts b/BackendAcademy/src/rewards/rewards.service.ts index 310474d33..4c4144b91 100644 --- a/BackendAcademy/src/rewards/rewards.service.ts +++ b/BackendAcademy/src/rewards/rewards.service.ts @@ -4,11 +4,19 @@ import { levelForXp, xpThresholdForLevel, xpToNextLevel, + LEADERBOARD_DEFAULT_TOP_N, + PRIZE_DISTRIBUTION_PERCENTAGES, + PRIZE_POOL_DEFAULT_CURRENCY, + PRIZE_POOL_DEFAULT_AMOUNT, } from './rewards.constants'; import type { LevelThreshold, UserProgressionResponse, ThresholdsResponse, + LeaderboardResponse, + UserLeaderboardPosition, + PrizePoolResponse, + PrizeDistribution, } from './interfaces/rewards.interfaces'; /** @@ -20,6 +28,21 @@ import type { */ const xpStore = new Map(); +/** + * In-memory prize pool store. + * + * Keyed by pool id → pool data including total amount and distribution records. + */ +interface PrizePoolData { + totalAmount: number; + currency: string; + distributedAt: Date | null; + createdAt: Date; + distribution: PrizeDistribution[]; +} + +const prizePoolStore = new Map(); + /** * Deterministic level title names for display purposes. * Covers levels 1-50. Titles repeat their tier name with a numeric suffix @@ -136,4 +159,159 @@ export class RewardsService { resetXp(userId: string): void { xpStore.set(userId, 0); } + + // ------------------------------------------------------------------------- + // Leaderboard + // ------------------------------------------------------------------------- + + /** + * Returns the top N users sorted by XP descending. + * + * @param topN Number of entries to return (defaults to LEADERBOARD_DEFAULT_TOP_N) + */ + getLeaderboard(topN: number = LEADERBOARD_DEFAULT_TOP_N): LeaderboardResponse { + const sorted = Array.from(xpStore.entries()) + .map(([userId, xp]) => ({ userId, xp, level: levelForXp(xp) })) + .sort((a, b) => b.xp - a.xp) + .slice(0, topN) + .map((entry, index) => ({ + rank: index + 1, + userId: entry.userId, + xp: entry.xp, + level: entry.level, + title: levelTitle(entry.level), + })); + + return { + leaderboard: sorted, + totalParticipants: xpStore.size, + }; + } + + /** + * Returns a single user's position on the leaderboard. + * + * @throws NotFoundException if the user has no XP record + */ + getUserLeaderboardPosition(userId: string): UserLeaderboardPosition { + if (!xpStore.has(userId)) { + throw new NotFoundException( + `User '${userId}' not found in the rewards system.`, + ); + } + + const entries = Array.from(xpStore.entries()).sort( + (a, b) => b[1] - a[1], + ); + const rank = entries.findIndex(([id]) => id === userId) + 1; + const xp = xpStore.get(userId)!; + const level = levelForXp(xp); + + return { + userId, + rank, + xp, + level, + title: levelTitle(level), + totalParticipants: xpStore.size, + }; + } + + // ------------------------------------------------------------------------- + // Prize pool + // ------------------------------------------------------------------------- + + /** + * Returns the most-recently created prize pool, or `null` if none exist. + */ + getPrizePool(): PrizePoolResponse | null { + const pools = Array.from(prizePoolStore.entries()); + if (pools.length === 0) return null; + + const [id, pool] = pools[pools.length - 1]; + return { id, ...pool }; + } + + /** + * Creates a new prize pool with the given amount and currency. + * The pool starts undistributed with an empty distribution list. + */ + createPrizePool( + totalAmount: number, + currency: string = PRIZE_POOL_DEFAULT_CURRENCY, + ): PrizePoolResponse { + if (totalAmount <= 0) { + throw new Error('Prize pool totalAmount must be positive.'); + } + + const id = `prize_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const pool: PrizePoolData = { + totalAmount, + currency, + distributedAt: null, + createdAt: new Date(), + distribution: [], + }; + prizePoolStore.set(id, pool); + return { id, ...pool }; + } + + /** + * Distributes the current undistributed prize pool to the top 10 + * leaderboard entries according to PRIZE_DISTRIBUTION_PERCENTAGES. + * + * If no prize pool exists, one is auto-created with the default amount. + * If the latest pool has already been distributed, this is a no-op + * that returns the existing pool. + * + * @returns The final state of the distributed prize pool + */ + distributePrizes(): PrizePoolResponse { + // Grab the latest pool, or create one + const pools = Array.from(prizePoolStore.entries()); + let id: string; + let pool: PrizePoolData; + + if (pools.length === 0) { + // Auto-create a default pool + const created = this.createPrizePool( + PRIZE_POOL_DEFAULT_AMOUNT, + PRIZE_POOL_DEFAULT_CURRENCY, + ); + // Re-fetch from store so we have a mutable reference + id = created.id; + pool = prizePoolStore.get(id)!; + } else { + [id, pool] = pools[pools.length - 1]; + if (pool.distributedAt) { + return { id, ...pool }; + } + } + + const leaderboard = this.getLeaderboard(10); + const distribution: PrizeDistribution[] = []; + + for (const entry of leaderboard.leaderboard) { + const config = PRIZE_DISTRIBUTION_PERCENTAGES.find( + (c) => c.rank === entry.rank, + ); + if (config) { + const amount = Math.floor( + (pool.totalAmount * config.percentage) / 100, + ); + distribution.push({ + rank: entry.rank, + userId: entry.userId, + amount, + distributedAt: new Date(), + }); + } + } + + pool.distribution = distribution; + pool.distributedAt = new Date(); + prizePoolStore.set(id, pool); + + return { id, ...pool }; + } }