import * as RedisClient from "../init/redis"; import LaterQueue from "../queues/later-queue"; import { matchesAPattern, kogascore, omit } from "./misc"; import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json"; import { Configuration, ValidModeRule, } from "@monkeytype/schemas/configuration"; import { LeaderboardEntry, RedisDailyLeaderboardEntry, RedisDailyLeaderboardEntrySchema, } from "@monkeytype/schemas/leaderboards"; import MonkeyError from "./error"; import { Mode, Mode2 } from "@monkeytype/schemas/shared"; import { getCurrentDayTimestamp } from "@monkeytype/util/date-and-time"; const dailyLeaderboardNamespace = "monkeytype:dailyleaderboard"; const scoresNamespace = `${dailyLeaderboardNamespace}:scores`; const resultsNamespace = `${dailyLeaderboardNamespace}:results`; export class DailyLeaderboard { private leaderboardResultsKeyName: string; private leaderboardScoresKeyName: string; private leaderboardModeKey: string; private customTime: number; private modeRule: ValidModeRule; constructor(modeRule: ValidModeRule, customTime = -1) { const { language, mode, mode2 } = modeRule; this.leaderboardModeKey = `${language}:${mode}:${mode2}`; this.leaderboardResultsKeyName = `${resultsNamespace}:${this.leaderboardModeKey}`; this.leaderboardScoresKeyName = `${scoresNamespace}:${this.leaderboardModeKey}`; this.customTime = customTime; this.modeRule = modeRule; } private getTodaysLeaderboardKeys(): { currentDayTimestamp: number; leaderboardScoresKey: string; leaderboardResultsKey: string; } { const currentDayTimestamp = this.customTime === -1 ? getCurrentDayTimestamp() : this.customTime; const leaderboardScoresKey = `${this.leaderboardScoresKeyName}:${currentDayTimestamp}`; const leaderboardResultsKey = `${this.leaderboardResultsKeyName}:${currentDayTimestamp}`; return { currentDayTimestamp, leaderboardScoresKey, leaderboardResultsKey, }; } public async addResult( entry: RedisDailyLeaderboardEntry, dailyLeaderboardsConfig: Configuration["dailyLeaderboards"], ): Promise { const connection = RedisClient.getConnection(); if (!connection || !dailyLeaderboardsConfig.enabled) { return -1; } const { currentDayTimestamp, leaderboardScoresKey, leaderboardResultsKey } = this.getTodaysLeaderboardKeys(); const { maxResults, leaderboardExpirationTimeInDays } = dailyLeaderboardsConfig; const leaderboardExpirationDurationInMilliseconds = leaderboardExpirationTimeInDays * 24 * 60 * 60 * 1000; const leaderboardExpirationTimeInSeconds = Math.floor( (currentDayTimestamp + leaderboardExpirationDurationInMilliseconds) / 1000, ); const resultScore = kogascore(entry.wpm, entry.acc, entry.timestamp); const rank = await connection.addResult( 2, leaderboardScoresKey, leaderboardResultsKey, maxResults, leaderboardExpirationTimeInSeconds, entry.uid, resultScore, JSON.stringify(entry), ); if ( isValidModeRule( this.modeRule, dailyLeaderboardsConfig.scheduleRewardsModeRules, ) ) { await LaterQueue.scheduleForTomorrow( "daily-leaderboard-results", this.leaderboardModeKey, this.modeRule, ); } if (rank === null) { return -1; } return rank + 1; } public async getResults( page: number, pageSize: number, dailyLeaderboardsConfig: Configuration["dailyLeaderboards"], premiumFeaturesEnabled: boolean, userIds?: string[], ): Promise<{ entries: LeaderboardEntry[]; count: number; minWpm: number; } | null> { const connection = RedisClient.getConnection(); if (!connection || !dailyLeaderboardsConfig.enabled) { return null; } if (page < 0 || pageSize < 0) { throw new MonkeyError(500, "Invalid page or pageSize"); } if (userIds?.length === 0) { return { entries: [], count: 0, minWpm: 0 }; } const isFriends = userIds !== undefined; const minRank = page * pageSize; const maxRank = minRank + pageSize - 1; const { leaderboardScoresKey, leaderboardResultsKey } = this.getTodaysLeaderboardKeys(); const [results, _, count, [_uid, minScore], ranks] = await connection.getResults( 2, leaderboardScoresKey, leaderboardResultsKey, minRank, maxRank, "false", userIds?.join(",") ?? "", ); const minWpm = minScore !== undefined ? parseInt(minScore.toString().slice(1, 6)) / 100 : 0; if (results === undefined) { throw new Error( "Redis returned undefined when getting daily leaderboard results", ); } let resultsWithRanks: LeaderboardEntry[] = results.map( (resultJSON, index) => { try { const parsed = parseJsonWithSchema( resultJSON, RedisDailyLeaderboardEntrySchema, ); return { ...parsed, rank: isFriends ? new Number(ranks[index]).valueOf() + 1 : minRank + index + 1, friendsRank: isFriends ? minRank + index + 1 : undefined, }; } catch (error) { throw new Error( `Failed to parse leaderboard entry at index ${index}: ${ error instanceof Error ? error.message : String(error) }`, ); } }, ); if (!premiumFeaturesEnabled) { resultsWithRanks = resultsWithRanks.map((it) => omit(it, ["isPremium"])); } return { entries: resultsWithRanks, count: parseInt(count), minWpm }; } public async getRank( uid: string, dailyLeaderboardsConfig: Configuration["dailyLeaderboards"], userIds?: string[], ): Promise { const connection = RedisClient.getConnection(); if (!connection || !dailyLeaderboardsConfig.enabled) { throw new Error("Redis connection is unavailable"); } if (userIds?.length === 0) { return null; } const { leaderboardScoresKey, leaderboardResultsKey } = this.getTodaysLeaderboardKeys(); const [rank, _score, result, friendsRank] = await connection.getRank( 2, leaderboardScoresKey, leaderboardResultsKey, uid, "false", userIds?.join(",") ?? "", ); if (rank === null || rank === undefined) { return null; } try { return { ...parseJsonWithSchema( result ?? "null", RedisDailyLeaderboardEntrySchema, ), rank: rank + 1, friendsRank: friendsRank !== undefined ? friendsRank + 1 : undefined, }; } catch (error) { throw new Error( `Failed to parse leaderboard entry: ${ error instanceof Error ? error.message : String(error) }`, ); } } } export async function purgeUserFromDailyLeaderboards( uid: string, configuration: Configuration["dailyLeaderboards"], ): Promise { const connection = RedisClient.getConnection(); if (!connection || !configuration.enabled) { return; } await connection.purgeResults(0, uid, dailyLeaderboardNamespace); } function isValidModeRule( modeRule: ValidModeRule, modeRules: ValidModeRule[], ): boolean { const { language, mode, mode2 } = modeRule; return modeRules.some((rule) => { const matchesLanguage = matchesAPattern(language, rule.language); const matchesMode = matchesAPattern(mode, rule.mode); const matchesMode2 = matchesAPattern(mode2, rule.mode2); return matchesLanguage && matchesMode && matchesMode2; }); } export function getDailyLeaderboard( language: string, mode: Mode, mode2: Mode2, dailyLeaderboardsConfig: Configuration["dailyLeaderboards"], customTimestamp = -1, ): DailyLeaderboard | null { const { validModeRules, enabled } = dailyLeaderboardsConfig; const modeRule: ValidModeRule = { language, mode, mode2 }; const isValidMode = isValidModeRule(modeRule, validModeRules); if (!enabled || !isValidMode) { return null; } return new DailyLeaderboard(modeRule, customTimestamp); } export const __testing = { namespace: dailyLeaderboardNamespace, };