diff --git a/backend/__tests__/api/controllers/leaderboard.spec.ts b/backend/__tests__/api/controllers/leaderboard.spec.ts new file mode 100644 index 000000000..eb98ec951 --- /dev/null +++ b/backend/__tests__/api/controllers/leaderboard.spec.ts @@ -0,0 +1,35 @@ +import request from "supertest"; +import app from "../../../src/app"; +import * as Configuration from "../../../src/init/configuration"; + +const mockApp = request(app); + +describe("leaderboards controller test", () => { + it("GET /leaderboards/xp/weekly", async () => { + const configSpy = jest + .spyOn(Configuration, "getCachedConfiguration") + .mockResolvedValue({ + leaderboards: { + weeklyXp: { + enabled: true, + expirationTimeInDays: 15, + xpRewardBrackets: [], + }, + }, + } as any); + + const response = await mockApp + .get("/leaderboards/xp/weekly") + .set({ + Accept: "application/json", + }) + .expect(200); + + expect(response.body).toEqual({ + message: "Weekly xp leaderboard retrieved", + data: [], + }); + + configSpy.mockRestore(); + }); +}); diff --git a/backend/__tests__/services/weeky-xp-leaderboard.spec.ts b/backend/__tests__/services/weeky-xp-leaderboard.spec.ts new file mode 100644 index 000000000..b9502c8e9 --- /dev/null +++ b/backend/__tests__/services/weeky-xp-leaderboard.spec.ts @@ -0,0 +1,27 @@ +import * as WeeklyXpLeaderboard from "../../src/services/weekly-xp-leaderboard"; + +const weeklyXpLeaderboardConfig = { + enabled: true, + expirationTimeInDays: 15, + xpRewardBrackets: [], +}; + +describe("Weekly Xp Leaderboard", () => { + it("should properly consider config", () => { + const weeklyXpLeaderboard = WeeklyXpLeaderboard.get( + weeklyXpLeaderboardConfig + ); + expect(weeklyXpLeaderboard).toBeInstanceOf( + WeeklyXpLeaderboard.WeeklyXpLeaderboard + ); + + weeklyXpLeaderboardConfig.enabled = false; + + const weeklyXpLeaderboardNull = WeeklyXpLeaderboard.get( + weeklyXpLeaderboardConfig + ); + expect(weeklyXpLeaderboardNull).toBeNull(); + }); + + // TODO: Setup Redis mock and test the rest of this +}); diff --git a/backend/__tests__/utils/daily-leaderboards.spec.ts b/backend/__tests__/utils/daily-leaderboards.spec.ts index 4765e6d2b..e7febe151 100644 --- a/backend/__tests__/utils/daily-leaderboards.spec.ts +++ b/backend/__tests__/utils/daily-leaderboards.spec.ts @@ -21,8 +21,6 @@ const dailyLeaderboardsConfig = { ], dailyLeaderboardCacheSize: 3, topResultsToAnnounce: 3, - maxXpReward: 0, - minXpReward: 0, xpRewardBrackets: [], }; diff --git a/backend/__tests__/utils/misc.spec.ts b/backend/__tests__/utils/misc.spec.ts index 12ef6765c..409f275e6 100644 --- a/backend/__tests__/utils/misc.spec.ts +++ b/backend/__tests__/utils/misc.spec.ts @@ -274,4 +274,110 @@ describe("Misc Utils", () => { expect(misc.getOrdinalNumberString(input)).toEqual(output); }); }); + + it("getStartOfWeekTimestamp", () => { + const testCases = [ + { + input: 1662400184017, // Mon Sep 05 2022 17:49:44 GMT+0000 + expected: 1662336000000, // Mon Sep 05 2022 00:00:00 GMT+0000 + }, + { + input: 1559771456000, // Wed Jun 05 2019 21:50:56 GMT+0000 + expected: 1559520000000, // Mon Jun 03 2019 00:00:00 GMT+0000 + }, + { + input: 1465163456000, // Sun Jun 05 2016 21:50:56 GMT+0000 + expected: 1464566400000, // Mon May 30 2016 00:00:00 GMT+0000 + }, + { + input: 1491515456000, // Thu Apr 06 2017 21:50:56 GMT+0000 + expected: 1491177600000, // Mon Apr 03 2017 00:00:00 GMT+0000 + }, + { + input: 1462507200000, // Fri May 06 2016 04:00:00 GMT+0000 + expected: 1462147200000, // Mon May 02 2016 00:00:00 GMT+0000 + }, + { + input: 1231218000000, // Tue Jan 06 2009 05:00:00 GMT+0000, + expected: 1231113600000, // Mon Jan 05 2009 00:00:00 GMT+0000 + }, + { + input: 1709420681000, // Sat Mar 02 2024 23:04:41 GMT+0000 + expected: 1708905600000, // Mon Feb 26 2024 00:00:00 GMT+0000 + }, + ]; + + testCases.forEach(({ input, expected }) => { + expect(misc.getStartOfWeekTimestamp(input)).toEqual(expected); + }); + }); + + it("getCurrentWeekTimestamp", () => { + Date.now = jest.fn(() => 825289481000); // Sun Feb 25 1996 23:04:41 GMT+0000 + + const currentWeek = misc.getCurrentWeekTimestamp(); + expect(currentWeek).toBe(824688000000); // Mon Feb 19 1996 00:00:00 GMT+0000 + }); + + it("mapRange", () => { + const testCases = [ + { + input: { + value: 123, + inMin: 0, + inMax: 200, + outMin: 0, + outMax: 1000, + clamp: false, + }, + expected: 615, + }, + { + input: { + value: 123, + inMin: 0, + inMax: 200, + outMin: 1000, + outMax: 0, + clamp: false, + }, + expected: 385, + }, + { + input: { + value: 10001, + inMin: 0, + inMax: 10000, + outMin: 0, + outMax: 1000, + clamp: false, + }, + expected: 1000.1, + }, + { + input: { + value: 10001, + inMin: 0, + inMax: 10000, + outMin: 0, + outMax: 1000, + clamp: true, + }, + expected: 1000, + }, + ]; + + testCases.forEach(({ input, expected }) => { + expect( + misc.mapRange( + input.value, + input.inMin, + input.inMax, + input.outMin, + input.outMax, + input.clamp + ) + ).toEqual(expected); + }); + }); }); diff --git a/backend/private/script.js b/backend/private/script.js index a3b7aca13..4deb631c9 100644 --- a/backend/private/script.js +++ b/backend/private/script.js @@ -1,11 +1,25 @@ let state = {}; let schema = {}; -const buildLabel = (elementType, text) => { +const buildLabel = (elementType, text, hintText) => { const labelElement = document.createElement("label"); labelElement.innerHTML = text; labelElement.style.fontWeight = elementType === "group" ? "bold" : "lighter"; + if (hintText) { + const hintElement = document.createElement("span"); + hintElement.classList.add("tooltip"); + hintElement.innerHTML = " ⓘ"; + + const hintTextElement = document.createElement("span"); + hintTextElement.classList.add("tooltip-text"); + hintTextElement.innerHTML = hintText; + + hintElement.appendChild(hintTextElement); + + labelElement.appendChild(hintElement); + } + return labelElement; }; @@ -132,10 +146,10 @@ const render = (state, schema) => { const parent = document.createElement("div"); parent.classList.add("form-element"); - const { type, label, fields, items } = schema; + const { type, label, hint, fields, items } = schema; if (label) { - parent.appendChild(buildLabel(type, label)); + parent.appendChild(buildLabel(type, label, hint)); } parent.id = path; diff --git a/backend/private/style.css b/backend/private/style.css index 8e3eb69a5..17a02e609 100644 --- a/backend/private/style.css +++ b/backend/private/style.css @@ -182,3 +182,17 @@ input[type="checkbox"] { transform: rotate(360deg); } } + +.tooltip:hover .tooltip-text { + display: block; +} + +.tooltip-text { + display: none; + color: var(--text-color); + background-color: var(--sub-alt-color); + position: absolute; + z-index: 1000; + padding: 10px; + border-radius: var(--roundness); +} diff --git a/backend/redis-scripts/add-result-increment.lua b/backend/redis-scripts/add-result-increment.lua new file mode 100644 index 000000000..fdef0f73b --- /dev/null +++ b/backend/redis-scripts/add-result-increment.lua @@ -0,0 +1,19 @@ +local redis_call = redis.call +local leaderboard_scores_key, leaderboard_results_key = KEYS[1], KEYS[2] + +local leaderboard_expiration_time = ARGV[1] +local user_id = ARGV[2] +local xp_gained = tonumber(ARGV[3]) +local user_data = ARGV[4] + +redis_call('ZINCRBY', leaderboard_scores_key, xp_gained, user_id) +redis_call('HSET', leaderboard_results_key, user_id, user_data) + +local number_of_results = redis_call('ZCARD', leaderboard_scores_key) + +if (number_of_results == 1) then + redis_call('EXPIREAT', leaderboard_scores_key, leaderboard_expiration_time) + redis_call('EXPIREAT', leaderboard_results_key, leaderboard_expiration_time) +end + +return redis_call('ZREVRANK', leaderboard_scores_key, user_id) diff --git a/backend/redis-scripts/get-results.lua b/backend/redis-scripts/get-results.lua index 9a0db82b9..bc15ff997 100644 --- a/backend/redis-scripts/get-results.lua +++ b/backend/redis-scripts/get-results.lua @@ -3,16 +3,22 @@ local leaderboard_scores_key, leaderboard_results_key = KEYS[1], KEYS[2] local min_rank = tonumber(ARGV[1]) local max_rank = tonumber(ARGV[2]) +local include_scores = ARGV[3] local results = {} +local scores = {} local scores_in_range = redis_call('ZRANGE', leaderboard_scores_key, min_rank, max_rank, 'REV') for _, user_id in ipairs(scores_in_range) do local result_data = redis_call('HGET', leaderboard_results_key, user_id) + if (include_scores == "true") then + scores[#scores + 1] = redis_call('ZSCORE', leaderboard_scores_key, user_id) + end + if (result_data ~= nil) then results[#results + 1] = result_data end end -return results +return {results, scores} diff --git a/backend/src/api/controllers/leaderboard.ts b/backend/src/api/controllers/leaderboard.ts index 453903244..adf8a07b9 100644 --- a/backend/src/api/controllers/leaderboard.ts +++ b/backend/src/api/controllers/leaderboard.ts @@ -1,9 +1,14 @@ import _ from "lodash"; -import { getCurrentDayTimestamp, MILLISECONDS_IN_DAY } from "../../utils/misc"; +import { + getCurrentDayTimestamp, + MILLISECONDS_IN_DAY, + getCurrentWeekTimestamp, +} from "../../utils/misc"; import { MonkeyResponse } from "../../utils/monkey-response"; import * as LeaderboardsDAL from "../../dal/leaderboards"; import MonkeyError from "../../utils/error"; import * as DailyLeaderboards from "../../utils/daily-leaderboards"; +import * as WeeklyXpLeaderboard from "../../services/weekly-xp-leaderboard"; export async function getLeaderboard( req: MonkeyTypes.Request @@ -129,3 +134,58 @@ export async function getDailyLeaderboardRank( return new MonkeyResponse("Daily leaderboard rank retrieved", rank); } + +function getWeeklyXpLeaderboardWithError( + req: MonkeyTypes.Request +): WeeklyXpLeaderboard.WeeklyXpLeaderboard { + const { weeksBefore } = req.query; + + const normalizedWeeksBefore = parseInt(weeksBefore as string, 10); + const currentWeekTimestamp = getCurrentWeekTimestamp(); + const weekBeforeTimestamp = + currentWeekTimestamp - normalizedWeeksBefore * MILLISECONDS_IN_DAY * 7; + + const customTimestamp = _.isNil(weeksBefore) ? -1 : weekBeforeTimestamp; + + const weeklyXpLeaderboard = WeeklyXpLeaderboard.get( + req.ctx.configuration.leaderboards.weeklyXp, + customTimestamp + ); + if (!weeklyXpLeaderboard) { + throw new MonkeyError(404, "XP leaderboard for this week not found."); + } + + return weeklyXpLeaderboard; +} + +export async function getWeeklyXpLeaderboardResults( + req: MonkeyTypes.Request +): Promise { + const { skip = 0, limit = 50 } = req.query; + + const minRank = parseInt(skip as string, 10); + const maxRank = minRank + parseInt(limit as string, 10) - 1; + + const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError(req); + const results = await weeklyXpLeaderboard.getResults( + minRank, + maxRank, + req.ctx.configuration.leaderboards.weeklyXp + ); + + return new MonkeyResponse("Weekly xp leaderboard retrieved", results); +} + +export async function getWeeklyXpLeaderboardRank( + req: MonkeyTypes.Request +): Promise { + const { uid } = req.ctx.decodedToken; + + const weeklyXpLeaderboard = getWeeklyXpLeaderboardWithError(req); + const rankEntry = await weeklyXpLeaderboard.getRank( + uid, + req.ctx.configuration.leaderboards.weeklyXp + ); + + return new MonkeyResponse("Weekly xp leaderboard rank retrieved", rankEntry); +} diff --git a/backend/src/api/controllers/result.ts b/backend/src/api/controllers/result.ts index 76c1d8545..709308765 100644 --- a/backend/src/api/controllers/result.ts +++ b/backend/src/api/controllers/result.ts @@ -36,6 +36,7 @@ import { getDailyLeaderboard } from "../../utils/daily-leaderboards"; import AutoRoleList from "../../constants/auto-roles"; import * as UserDAL from "../../dal/user"; import { buildMonkeyMail } from "../../utils/monkey-mail"; +import * as WeeklyXpLeaderboard from "../../services/weekly-xp-leaderboard"; try { if (anticheatImplemented() === false) throw new Error("undefined"); @@ -116,6 +117,7 @@ interface AddResultData { tagPbs: any[]; insertedId: ObjectId; dailyLeaderboardRank?: number; + weeklyXpLeaderboardRank?: number; xp: number; dailyXpBonus: boolean; xpBreakdown: Record; @@ -344,14 +346,15 @@ export async function addResult( delete result.challenge; } - let tt = 0; + let totalDurationTypedSeconds = 0; let afk = result.afkDuration; if (afk == undefined) { afk = 0; } - tt = result.testDuration + result.incompleteTestSeconds - afk; - updateTypingStats(uid, result.restartCount, tt); - PublicDAL.updateStats(result.restartCount, tt); + totalDurationTypedSeconds = + result.testDuration + result.incompleteTestSeconds - afk; + updateTypingStats(uid, result.restartCount, totalDurationTypedSeconds); + PublicDAL.updateStats(result.restartCount, totalDurationTypedSeconds); const dailyLeaderboardsConfig = req.ctx.configuration.dailyLeaderboards; const dailyLeaderboard = getDailyLeaderboard( @@ -370,10 +373,9 @@ export async function addResult( !user.banned && (process.env.MODE === "dev" || (user.timeTyping ?? 0) > 7200); - if (dailyLeaderboard && validResultCriteria) { - //get the selected badge id - const badgeId = user.inventory?.badges?.find((b) => b.selected)?.id; + const selectedBadgeId = user.inventory?.badges?.find((b) => b.selected)?.id; + if (dailyLeaderboard && validResultCriteria) { incrementDailyLeaderboard(result.mode, result.mode2, result.language); dailyLeaderboardRank = await dailyLeaderboard.addResult( { @@ -386,7 +388,7 @@ export async function addResult( uid, discordAvatar: user.discordAvatar, discordId: user.discordId, - badgeId, + badgeId: selectedBadgeId, }, dailyLeaderboardsConfig ); @@ -402,6 +404,37 @@ export async function addResult( streak ); + const weeklyXpLeaderboardConfig = req.ctx.configuration.leaderboards.weeklyXp; + let weeklyXpLeaderboardRank = -1; + const eligibleForWeeklyXpLeaderboard = + !user.banned && + (process.env.MODE === "dev" || (user.timeTyping ?? 0) > 7200); + + const weeklyXpLeaderboard = WeeklyXpLeaderboard.get( + weeklyXpLeaderboardConfig + ); + if ( + eligibleForWeeklyXpLeaderboard && + xpGained.xp > 0 && + weeklyXpLeaderboard + ) { + weeklyXpLeaderboardRank = await weeklyXpLeaderboard.addResult( + weeklyXpLeaderboardConfig, + { + entry: { + uid, + name: user.name, + discordAvatar: user.discordAvatar, + discordId: user.discordId, + badgeId: selectedBadgeId, + lastActivityTimestamp: Date.now(), + }, + xpGained: xpGained.xp, + timeTypedSeconds: totalDurationTypedSeconds, + } + ); + } + if (result.bailedOut === false) delete result.bailedOut; if (result.blindMode === false) delete result.blindMode; if (result.lazyMode === false) delete result.lazyMode; @@ -443,6 +476,11 @@ export async function addResult( if (dailyLeaderboardRank !== -1) { data.dailyLeaderboardRank = dailyLeaderboardRank; } + + if (weeklyXpLeaderboardRank !== -1) { + data.weeklyXpLeaderboardRank = weeklyXpLeaderboardRank; + } + incrementResult(result); return new MonkeyResponse("Result saved", data); diff --git a/backend/src/api/routes/leaderboards.ts b/backend/src/api/routes/leaderboards.ts index 8366e71d9..fc891cd4f 100644 --- a/backend/src/api/routes/leaderboards.ts +++ b/backend/src/api/routes/leaderboards.ts @@ -78,4 +78,40 @@ router.get( asyncHandler(LeaderboardController.getDailyLeaderboardRank) ); +const BASE_XP_LEADERBOARD_VALIDATION_SCHEMA = { + skip: joi.number().min(0), + limit: joi.number().min(0).max(50), +}; + +const WEEKLY_XP_LEADERBOARD_VALIDATION_SCHEMA = { + ...BASE_XP_LEADERBOARD_VALIDATION_SCHEMA, + weeksBefore: joi.number().min(1).max(1), +}; + +const requireWeeklyXpLeaderboardEnabled = validateConfiguration({ + criteria: (configuration) => { + return configuration.leaderboards.weeklyXp.enabled; + }, + invalidMessage: "Weekly XP leaderboards are not available at this time.", +}); + +router.get( + "/xp/weekly", + requireWeeklyXpLeaderboardEnabled, + authenticateRequest({ isPublic: true }), + withApeRateLimiter(RateLimit.leaderboardsGet), + validateRequest({ + query: WEEKLY_XP_LEADERBOARD_VALIDATION_SCHEMA, + }), + asyncHandler(LeaderboardController.getWeeklyXpLeaderboardResults) +); + +router.get( + "/xp/weekly/rank", + requireWeeklyXpLeaderboardEnabled, + authenticateRequest(), + withApeRateLimiter(RateLimit.leaderboardsGet), + asyncHandler(LeaderboardController.getWeeklyXpLeaderboardRank) +); + export default router; diff --git a/backend/src/constants/base-configuration.ts b/backend/src/constants/base-configuration.ts index 8936e1d23..97f352e41 100644 --- a/backend/src/constants/base-configuration.ts +++ b/backend/src/constants/base-configuration.ts @@ -75,11 +75,19 @@ export const BASE_CONFIGURATION: MonkeyTypes.Configuration = { topResultsToAnnounce: 1, // This should never be 0. Setting to zero will announce all results. xpRewardBrackets: [], }, + leaderboards: { + weeklyXp: { + enabled: false, + expirationTimeInDays: 0, // This should atleast be 15 + xpRewardBrackets: [], + }, + }, }; interface BaseSchema { type: string; label?: string; + hint?: string; } interface NumberSchema extends BaseSchema { @@ -394,6 +402,7 @@ export const CONFIGURATION_FORM_SCHEMA: ObjectSchema = { type: "number", label: "Top Results To Announce", min: 1, + hint: "This should atleast be 1. Setting to zero is very bad.", }, xpRewardBrackets: { type: "array", @@ -427,5 +436,57 @@ export const CONFIGURATION_FORM_SCHEMA: ObjectSchema = { }, }, }, + leaderboards: { + type: "object", + label: "Leaderboards", + fields: { + weeklyXp: { + type: "object", + label: "Weekly XP", + fields: { + enabled: { + type: "boolean", + label: "Enabled", + }, + expirationTimeInDays: { + type: "number", + label: "Expiration time in days", + min: 0, + hint: "This should atleast be 15, to allow for past week queries.", + }, + xpRewardBrackets: { + type: "array", + label: "XP Reward Brackets", + items: { + type: "object", + label: "Bracket", + fields: { + minRank: { + type: "number", + label: "Min Rank", + min: 1, + }, + maxRank: { + type: "number", + label: "Max Rank", + min: 1, + }, + minReward: { + type: "number", + label: "Min Reward", + min: 0, + }, + maxReward: { + type: "number", + label: "Max Reward", + min: 0, + }, + }, + }, + }, + }, + }, + }, + }, }, }; diff --git a/backend/src/services/weekly-xp-leaderboard.ts b/backend/src/services/weekly-xp-leaderboard.ts new file mode 100644 index 000000000..25c1614dc --- /dev/null +++ b/backend/src/services/weekly-xp-leaderboard.ts @@ -0,0 +1,189 @@ +import * as RedisClient from "../init/redis"; +import { getCurrentWeekTimestamp } from "../utils/misc"; + +interface InternalWeeklyXpLeaderboardEntry { + uid: string; + name: string; + discordAvatar?: string; + discordId?: string; + badgeId?: number; + lastActivityTimestamp: number; +} + +interface WeeklyXpLeaderboardEntry extends InternalWeeklyXpLeaderboardEntry { + totalXp: number; + rank: number; + count?: number; + timeTypedSeconds: number; +} + +interface AddResultOpts { + entry: InternalWeeklyXpLeaderboardEntry; + xpGained: number; + timeTypedSeconds: number; +} + +const weeklyXpLeaderboardLeaderboardNamespace = + "monkeytype:weekly-xp-leaderboard"; +const scoresNamespace = `${weeklyXpLeaderboardLeaderboardNamespace}:scores`; +const resultsNamespace = `${weeklyXpLeaderboardLeaderboardNamespace}:results`; + +export class WeeklyXpLeaderboard { + private weeklyXpLeaderboardResultsKeyName: string; + private weeklyXpLeaderboardScoresKeyName: string; + private customTime: number; + + constructor(customTime = -1) { + this.weeklyXpLeaderboardResultsKeyName = resultsNamespace; + this.weeklyXpLeaderboardScoresKeyName = scoresNamespace; + this.customTime = customTime; + } + + private getThisWeeksXpLeaderboardKeys(): { + currentWeekTimestamp: number; + weeklyXpLeaderboardScoresKey: string; + weeklyXpLeaderboardResultsKey: string; + } { + const currentWeekTimestamp = + this.customTime === -1 ? getCurrentWeekTimestamp() : this.customTime; + + const weeklyXpLeaderboardScoresKey = `${this.weeklyXpLeaderboardScoresKeyName}:${currentWeekTimestamp}`; + const weeklyXpLeaderboardResultsKey = `${this.weeklyXpLeaderboardResultsKeyName}:${currentWeekTimestamp}`; + + return { + currentWeekTimestamp, + weeklyXpLeaderboardScoresKey, + weeklyXpLeaderboardResultsKey, + }; + } + + public async addResult( + weeklyXpLeaderboardConfig: MonkeyTypes.Configuration["leaderboards"]["weeklyXp"], + opts: AddResultOpts + ): Promise { + const { entry, xpGained, timeTypedSeconds } = opts; + + const connection = RedisClient.getConnection(); + if (!connection || !weeklyXpLeaderboardConfig.enabled) { + return -1; + } + + const { + currentWeekTimestamp, + weeklyXpLeaderboardScoresKey, + weeklyXpLeaderboardResultsKey, + } = this.getThisWeeksXpLeaderboardKeys(); + + const { expirationTimeInDays } = weeklyXpLeaderboardConfig; + const weeklyXpLeaderboardExpirationDurationInMilliseconds = + expirationTimeInDays * 24 * 60 * 60 * 1000; + + const weeklyXpLeaderboardExpirationTimeInSeconds = Math.floor( + (currentWeekTimestamp + + weeklyXpLeaderboardExpirationDurationInMilliseconds) / + 1000 + ); + + const currentEntry = await connection.hget( + weeklyXpLeaderboardResultsKey, + entry.uid + ); + const totalTimeTypedSeconds = + timeTypedSeconds + + ((currentEntry && JSON.parse(currentEntry)?.timeTypedSeconds) || 0); + + // @ts-ignore + const rank: number = await connection.addResultIncrement( + 2, + weeklyXpLeaderboardScoresKey, + weeklyXpLeaderboardResultsKey, + weeklyXpLeaderboardExpirationTimeInSeconds, + entry.uid, + xpGained, + JSON.stringify({ ...entry, timeTypedSeconds: totalTimeTypedSeconds }) + ); + + return rank + 1; + } + + public async getResults( + minRank: number, + maxRank: number, + weeklyXpLeaderboardConfig: MonkeyTypes.Configuration["leaderboards"]["weeklyXp"] + ): Promise { + const connection = RedisClient.getConnection(); + if (!connection || !weeklyXpLeaderboardConfig.enabled) { + return []; + } + + const { weeklyXpLeaderboardScoresKey, weeklyXpLeaderboardResultsKey } = + this.getThisWeeksXpLeaderboardKeys(); + + // @ts-ignore + const [results, scores]: string[][] = await connection.getResults( + 2, // How many of the arguments are redis keys (https://redis.io/docs/manual/programmability/lua-api/) + weeklyXpLeaderboardScoresKey, + weeklyXpLeaderboardResultsKey, + minRank, + maxRank, + "true" + ); + + const resultsWithRanks: WeeklyXpLeaderboardEntry[] = results.map( + (resultJSON: string, index: number) => ({ + ...JSON.parse(resultJSON), + rank: minRank + index + 1, + totalXp: parseInt(scores[index], 10), + }) + ); + + return resultsWithRanks; + } + + public async getRank( + uid: string, + weeklyXpLeaderboardConfig: MonkeyTypes.Configuration["leaderboards"]["weeklyXp"] + ): Promise { + const connection = RedisClient.getConnection(); + if (!connection || !weeklyXpLeaderboardConfig.enabled) { + return null; + } + + const { weeklyXpLeaderboardScoresKey, weeklyXpLeaderboardResultsKey } = + this.getThisWeeksXpLeaderboardKeys(); + + connection.set; + + const [[, rank], [, totalXp], [, count], [, result]] = await connection + .multi() + .zrevrank(weeklyXpLeaderboardScoresKey, uid) + .zscore(weeklyXpLeaderboardScoresKey, uid) + .zcard(weeklyXpLeaderboardScoresKey) + .hget(weeklyXpLeaderboardResultsKey, uid) + .exec(); + + if (rank === null) { + return null; + } + + return { + rank: rank + 1, + count: count ?? 0, + totalXp: parseInt(totalXp, 10), + ...JSON.parse(result ?? "null"), + }; + } +} + +export function get( + weeklyXpLeaderboardConfig: MonkeyTypes.Configuration["leaderboards"]["weeklyXp"], + customTimestamp?: number +): WeeklyXpLeaderboard | null { + const { enabled } = weeklyXpLeaderboardConfig; + + if (!enabled) { + return null; + } + + return new WeeklyXpLeaderboard(customTimestamp); +} diff --git a/backend/src/types/types.d.ts b/backend/src/types/types.d.ts index 5aa41a0ca..b3fb0dbf1 100644 --- a/backend/src/types/types.d.ts +++ b/backend/src/types/types.d.ts @@ -80,6 +80,13 @@ declare namespace MonkeyTypes { topResultsToAnnounce: number; xpRewardBrackets: RewardBracket[]; }; + leaderboards: { + weeklyXp: { + enabled: boolean; + expirationTimeInDays: number; + xpRewardBrackets: RewardBracket[]; + }; + }; } interface RewardBracket { diff --git a/backend/src/utils/daily-leaderboards.ts b/backend/src/utils/daily-leaderboards.ts index 6ad9f4116..21a458c0a 100644 --- a/backend/src/utils/daily-leaderboards.ts +++ b/backend/src/utils/daily-leaderboards.ts @@ -108,12 +108,13 @@ export class DailyLeaderboard { this.getTodaysLeaderboardKeys(); // @ts-ignore - const results: string[] = await connection.getResults( + const [results]: string[][] = await connection.getResults( 2, leaderboardScoresKey, leaderboardResultsKey, minRank, - maxRank + maxRank, + "false" ); const resultsWithRanks: DailyLeaderboardEntry[] = results.map( diff --git a/backend/src/utils/misc.ts b/backend/src/utils/misc.ts index ab595ab58..2666eb824 100644 --- a/backend/src/utils/misc.ts +++ b/backend/src/utils/misc.ts @@ -204,3 +204,17 @@ export function mapRange( return result; } + +export function getStartOfWeekTimestamp(timestamp: number): number { + const date = new Date(timestamp); + + const monday = date.getDate() - (date.getDay() || 7) + 1; + date.setDate(monday); + + return getStartOfDayTimestamp(date.getTime()); +} + +export function getCurrentWeekTimestamp(): number { + const currentTime = Date.now(); + return getStartOfWeekTimestamp(currentTime); +}