mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2024-09-20 07:16:17 +08:00
Add weekly xp leaderboards backend (#3511) Bruception
* Add weekly seasons * Fix test * Add week timestamp tests * Add unit tests * Fix weeks before calculation * Update user.spec.ts * Remove minXp maxXp reward config * Record total time typed + last activity timestamp * Season -> Weekly XP Leaderboard * prettier * Add config hints * Update leaderboard.ts * monkeytype Co-authored-by: Miodec <jack@monkeytype.com>
This commit is contained in:
parent
550695c1ab
commit
87b89e0d57
35
backend/__tests__/api/controllers/leaderboard.spec.ts
Normal file
35
backend/__tests__/api/controllers/leaderboard.spec.ts
Normal file
|
@ -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();
|
||||
});
|
||||
});
|
27
backend/__tests__/services/weeky-xp-leaderboard.spec.ts
Normal file
27
backend/__tests__/services/weeky-xp-leaderboard.spec.ts
Normal file
|
@ -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
|
||||
});
|
|
@ -21,8 +21,6 @@ const dailyLeaderboardsConfig = {
|
|||
],
|
||||
dailyLeaderboardCacheSize: 3,
|
||||
topResultsToAnnounce: 3,
|
||||
maxXpReward: 0,
|
||||
minXpReward: 0,
|
||||
xpRewardBrackets: [],
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
19
backend/redis-scripts/add-result-increment.lua
Normal file
19
backend/redis-scripts/add-result-increment.lua
Normal file
|
@ -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)
|
|
@ -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}
|
||||
|
|
|
@ -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<MonkeyResponse> {
|
||||
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<MonkeyResponse> {
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -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<string, number>;
|
||||
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
189
backend/src/services/weekly-xp-leaderboard.ts
Normal file
189
backend/src/services/weekly-xp-leaderboard.ts
Normal file
|
@ -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<number> {
|
||||
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<WeeklyXpLeaderboardEntry[]> {
|
||||
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<WeeklyXpLeaderboardEntry | null> {
|
||||
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);
|
||||
}
|
7
backend/src/types/types.d.ts
vendored
7
backend/src/types/types.d.ts
vendored
|
@ -80,6 +80,13 @@ declare namespace MonkeyTypes {
|
|||
topResultsToAnnounce: number;
|
||||
xpRewardBrackets: RewardBracket[];
|
||||
};
|
||||
leaderboards: {
|
||||
weeklyXp: {
|
||||
enabled: boolean;
|
||||
expirationTimeInDays: number;
|
||||
xpRewardBrackets: RewardBracket[];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface RewardBracket {
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue