mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2024-09-20 15:26:15 +08:00
Reward daily leaderboards and refactor mail API (#3508) Bruception
* Reward daily leaderboards and refactor mail API * Reorder * Change mail template
This commit is contained in:
parent
6f22eaa1fc
commit
20a5cac5e6
|
@ -501,19 +501,102 @@ describe("UserDal", () => {
|
|||
"TestID",
|
||||
[
|
||||
{
|
||||
getTemplate: (user) => ({
|
||||
subject: `Hello ${user.name}!`,
|
||||
}),
|
||||
subject: `Hello!`,
|
||||
} as any,
|
||||
],
|
||||
0
|
||||
{
|
||||
enabled: true,
|
||||
maxMail: 100,
|
||||
}
|
||||
);
|
||||
|
||||
const inbox = await UserDAL.getInbox("TestID");
|
||||
|
||||
expect(inbox).toStrictEqual([
|
||||
{
|
||||
subject: "Hello test name!",
|
||||
subject: "Hello!",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("addToInbox discards mail if inbox is full", async () => {
|
||||
await UserDAL.addUser("test name", "test email", "TestID");
|
||||
|
||||
const config = {
|
||||
enabled: true,
|
||||
maxMail: 1,
|
||||
};
|
||||
|
||||
await UserDAL.addToInbox(
|
||||
"TestID",
|
||||
[
|
||||
{
|
||||
subject: "Hello 1!",
|
||||
} as any,
|
||||
],
|
||||
config
|
||||
);
|
||||
|
||||
await UserDAL.addToInbox(
|
||||
"TestID",
|
||||
[
|
||||
{
|
||||
subject: "Hello 2!",
|
||||
} as any,
|
||||
],
|
||||
config
|
||||
);
|
||||
|
||||
const inbox = await UserDAL.getInbox("TestID");
|
||||
|
||||
expect(inbox).toStrictEqual([
|
||||
{
|
||||
subject: "Hello 2!",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("addToInboxBulk should add mail to multiple users", async () => {
|
||||
await UserDAL.addUser("test name", "test email", "TestID");
|
||||
await UserDAL.addUser("test name 2", "test email 2", "TestID2");
|
||||
|
||||
await UserDAL.addToInboxBulk(
|
||||
[
|
||||
{
|
||||
uid: "TestID",
|
||||
mail: [
|
||||
{
|
||||
subject: `Hello!`,
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: "TestID2",
|
||||
mail: [
|
||||
{
|
||||
subject: `Hello 2!`,
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
],
|
||||
{
|
||||
enabled: true,
|
||||
maxMail: 100,
|
||||
}
|
||||
);
|
||||
|
||||
const inbox = await UserDAL.getInbox("TestID");
|
||||
const inbox2 = await UserDAL.getInbox("TestID2");
|
||||
|
||||
expect(inbox).toStrictEqual([
|
||||
{
|
||||
subject: "Hello!",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(inbox2).toStrictEqual([
|
||||
{
|
||||
subject: "Hello 2!",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
@ -21,7 +21,8 @@ const dailyLeaderboardsConfig = {
|
|||
],
|
||||
dailyLeaderboardCacheSize: 3,
|
||||
topResultsToAnnounce: 3,
|
||||
xpReward: 0,
|
||||
maxXpReward: 0,
|
||||
minXpReward: 0,
|
||||
};
|
||||
|
||||
describe("Daily Leaderboards", () => {
|
||||
|
|
|
@ -205,4 +205,73 @@ describe("Misc Utils", () => {
|
|||
expect(misc.sanitizeString(input)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
it("getOrdinalNumberString", () => {
|
||||
const testCases = [
|
||||
{
|
||||
input: 0,
|
||||
output: "0th",
|
||||
},
|
||||
{
|
||||
input: 1,
|
||||
output: "1st",
|
||||
},
|
||||
{
|
||||
input: 2,
|
||||
output: "2nd",
|
||||
},
|
||||
{
|
||||
input: 3,
|
||||
output: "3rd",
|
||||
},
|
||||
{
|
||||
input: 4,
|
||||
output: "4th",
|
||||
},
|
||||
{
|
||||
input: 10,
|
||||
output: "10th",
|
||||
},
|
||||
{
|
||||
input: 11,
|
||||
output: "11th",
|
||||
},
|
||||
{
|
||||
input: 12,
|
||||
output: "12th",
|
||||
},
|
||||
{
|
||||
input: 13,
|
||||
output: "13th",
|
||||
},
|
||||
{
|
||||
input: 100,
|
||||
output: "100th",
|
||||
},
|
||||
{
|
||||
input: 101,
|
||||
output: "101st",
|
||||
},
|
||||
{
|
||||
input: 102,
|
||||
output: "102nd",
|
||||
},
|
||||
{
|
||||
input: 103,
|
||||
output: "103rd",
|
||||
},
|
||||
{
|
||||
input: 104,
|
||||
output: "104th",
|
||||
},
|
||||
{
|
||||
input: 93589423,
|
||||
output: "93589423rd",
|
||||
},
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, output }) => {
|
||||
expect(misc.getOrdinalNumberString(input)).toEqual(output);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,7 +6,6 @@ describe("Monkey Mail", () => {
|
|||
subject: "",
|
||||
body: "",
|
||||
timestamp: Date.now(),
|
||||
getTemplate: (): any => ({}),
|
||||
};
|
||||
|
||||
const mail = buildMonkeyMail(mailConfig) as any;
|
||||
|
@ -17,6 +16,5 @@ describe("Monkey Mail", () => {
|
|||
expect(mail.timestamp).toBeDefined();
|
||||
expect(mail.read).toBe(false);
|
||||
expect(mail.rewards).toEqual([]);
|
||||
expect(mail.getTemplate).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -256,16 +256,10 @@ export async function addResult(
|
|||
);
|
||||
if (didUserGetBanned) {
|
||||
const mail = buildMonkeyMail({
|
||||
getTemplate: () => ({
|
||||
subject: "Banned",
|
||||
body: "Your account has been automatically banned for triggering the anticheat system. If you believe this is a mistake, please contact support.",
|
||||
}),
|
||||
subject: "Banned",
|
||||
body: "Your account has been automatically banned for triggering the anticheat system. If you believe this is a mistake, please contact support.",
|
||||
});
|
||||
UserDAL.addToInbox(
|
||||
uid,
|
||||
[mail],
|
||||
req.ctx.configuration.users.inbox.maxMail
|
||||
);
|
||||
UserDAL.addToInbox(uid, [mail], req.ctx.configuration.users.inbox);
|
||||
}
|
||||
}
|
||||
const status = MonkeyStatusCodes.BOT_DETECTED;
|
||||
|
|
|
@ -67,7 +67,8 @@ export const BASE_CONFIGURATION: MonkeyTypes.Configuration = {
|
|||
// GOTCHA! MUST ATLEAST BE 1, LRUCache module will make process crash and die
|
||||
dailyLeaderboardCacheSize: 1,
|
||||
topResultsToAnnounce: 1, // This should never be 0. Setting to zero will announce all results.
|
||||
xpReward: 0,
|
||||
minXpReward: 0,
|
||||
maxXpReward: 0,
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -367,9 +368,14 @@ export const CONFIGURATION_FORM_SCHEMA: ObjectSchema = {
|
|||
label: "Top Results To Announce",
|
||||
min: 1,
|
||||
},
|
||||
xpReward: {
|
||||
minXpReward: {
|
||||
type: "number",
|
||||
label: "XP Reward",
|
||||
label: "Minimum XP Reward",
|
||||
min: 0,
|
||||
},
|
||||
maxXpReward: {
|
||||
type: "number",
|
||||
label: "Maximum XP Reward",
|
||||
min: 0,
|
||||
},
|
||||
},
|
||||
|
|
|
@ -7,7 +7,6 @@ import MonkeyError from "../utils/error";
|
|||
import { Collection, ObjectId, WithId, Long, UpdateFilter } from "mongodb";
|
||||
import Logger from "../utils/logger";
|
||||
import { flattenObjectDeep } from "../utils/misc";
|
||||
import { MonkeyMailWithTemplate } from "../utils/monkey-mail";
|
||||
|
||||
const SECONDS_PER_HOUR = 3600;
|
||||
|
||||
|
@ -745,37 +744,60 @@ export async function getInbox(
|
|||
return user.inbox ?? [];
|
||||
}
|
||||
|
||||
export async function addToInbox(
|
||||
uid: string,
|
||||
mail: MonkeyMailWithTemplate[],
|
||||
maxInboxSize: number
|
||||
interface AddToInboxBulkEntry {
|
||||
uid: string;
|
||||
mail: MonkeyTypes.MonkeyMail[];
|
||||
}
|
||||
|
||||
export async function addToInboxBulk(
|
||||
entries: AddToInboxBulkEntry[],
|
||||
inboxConfig: MonkeyTypes.Configuration["users"]["inbox"]
|
||||
): Promise<void> {
|
||||
const user = await getUser(uid, "add to inbox");
|
||||
const { enabled, maxMail } = inboxConfig;
|
||||
|
||||
const inbox = user.inbox ?? [];
|
||||
|
||||
for (let i = 0; i < inbox.length + mail.length - maxInboxSize; i++) {
|
||||
inbox.pop();
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const evaluatedMail: MonkeyTypes.MonkeyMail[] = mail.map((mail) => {
|
||||
return _.omit(
|
||||
{
|
||||
...mail,
|
||||
...(mail.getTemplate && mail.getTemplate(user)),
|
||||
const bulk = getUsersCollection().initializeUnorderedBulkOp();
|
||||
|
||||
entries.forEach((entry) => {
|
||||
bulk.find({ uid: entry.uid }).updateOne({
|
||||
$push: {
|
||||
inbox: {
|
||||
$each: entry.mail,
|
||||
$position: 0, // Prepends to the inbox
|
||||
$slice: maxMail, // Keeps inbox size to maxInboxSize, maxMail the oldest
|
||||
},
|
||||
},
|
||||
"getTemplate"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
inbox.unshift(...evaluatedMail);
|
||||
const newInbox = inbox.sort((a, b) => b.timestamp - a.timestamp);
|
||||
await bulk.execute();
|
||||
}
|
||||
|
||||
export async function addToInbox(
|
||||
uid: string,
|
||||
mail: MonkeyTypes.MonkeyMail[],
|
||||
inboxConfig: MonkeyTypes.Configuration["users"]["inbox"]
|
||||
): Promise<void> {
|
||||
const { enabled, maxMail } = inboxConfig;
|
||||
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
await getUsersCollection().updateOne(
|
||||
{ uid },
|
||||
{
|
||||
$set: {
|
||||
inbox: newInbox,
|
||||
uid,
|
||||
},
|
||||
{
|
||||
$push: {
|
||||
inbox: {
|
||||
$each: mail,
|
||||
$position: 0, // Prepends to the inbox
|
||||
$slice: maxMail, // Keeps inbox size to maxMail, discarding the oldest
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
import { CronJob } from "cron";
|
||||
import { getCurrentDayTimestamp } from "../utils/misc";
|
||||
import {
|
||||
getCurrentDayTimestamp,
|
||||
getOrdinalNumberString,
|
||||
mapRange,
|
||||
} from "../utils/misc";
|
||||
import { getCachedConfiguration } from "../init/configuration";
|
||||
import { DailyLeaderboard } from "../utils/daily-leaderboards";
|
||||
import { announceDailyLeaderboardTopResults } from "../tasks/george";
|
||||
import { addToInbox } from "../dal/user";
|
||||
import { addToInboxBulk } from "../dal/user";
|
||||
import { buildMonkeyMail } from "../utils/monkey-mail";
|
||||
|
||||
const CRON_SCHEDULE = "1 0 * * *"; // At 00:01.
|
||||
|
@ -37,38 +41,52 @@ async function announceDailyLeaderboard(
|
|||
yesterday
|
||||
);
|
||||
|
||||
const topResults = await dailyLeaderboard.getResults(
|
||||
const allResults = await dailyLeaderboard.getResults(
|
||||
0,
|
||||
dailyLeaderboardsConfig.topResultsToAnnounce - 1,
|
||||
-1,
|
||||
dailyLeaderboardsConfig
|
||||
);
|
||||
if (topResults.length === 0) {
|
||||
|
||||
if (allResults.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (inboxConfig.enabled) {
|
||||
const { xpReward } = dailyLeaderboardsConfig;
|
||||
const { maxXpReward, minXpReward, maxResults } = dailyLeaderboardsConfig;
|
||||
|
||||
const inboxPromises = topResults.map(async (entry) => {
|
||||
const mail = buildMonkeyMail({
|
||||
const mailEntries = allResults.map((entry) => {
|
||||
const rank = entry.rank ?? maxResults;
|
||||
|
||||
const placementString = getOrdinalNumberString(rank);
|
||||
const xpReward = Math.floor(
|
||||
mapRange(rank, 1, maxResults, maxXpReward, minXpReward)
|
||||
);
|
||||
|
||||
const rewardMail = buildMonkeyMail({
|
||||
subject: "Daily leaderboard placement",
|
||||
body: `Congratulations ${entry.name} on placing ${placementString} in the ${language} ${mode} ${mode2} daily leaderboard!`,
|
||||
rewards: [
|
||||
{
|
||||
type: "xp",
|
||||
item: xpReward,
|
||||
},
|
||||
],
|
||||
getTemplate: (user) => ({
|
||||
subject: `${xpReward} XP for top placement in the daily leaderboard!`,
|
||||
body: `Congratulations ${user.name} on placing top ${entry.rank} in the ${language} ${mode} ${mode2} daily leaderboard! Claim your ${xpReward} xp!`,
|
||||
}),
|
||||
});
|
||||
|
||||
return await addToInbox(entry.uid, [mail], inboxConfig.maxMail);
|
||||
return {
|
||||
uid: entry.uid,
|
||||
mail: [rewardMail],
|
||||
};
|
||||
});
|
||||
|
||||
await Promise.allSettled(inboxPromises);
|
||||
await addToInboxBulk(mailEntries, inboxConfig);
|
||||
}
|
||||
|
||||
const topResults = allResults.slice(
|
||||
0,
|
||||
dailyLeaderboardsConfig.topResultsToAnnounce
|
||||
);
|
||||
|
||||
const leaderboardId = `${mode} ${mode2} ${language}`;
|
||||
await announceDailyLeaderboardTopResults(
|
||||
leaderboardId,
|
||||
|
|
3
backend/src/types/types.d.ts
vendored
3
backend/src/types/types.d.ts
vendored
|
@ -72,7 +72,8 @@ declare namespace MonkeyTypes {
|
|||
validModeRules: ValidModeRule[];
|
||||
dailyLeaderboardCacheSize: number;
|
||||
topResultsToAnnounce: number;
|
||||
xpReward: number;
|
||||
maxXpReward: number;
|
||||
minXpReward: number;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -155,3 +155,22 @@ export function sanitizeString(str: string | undefined): string | undefined {
|
|||
.trim()
|
||||
.replace(/\s{3,}/g, " ");
|
||||
}
|
||||
|
||||
const suffixes = ["th", "st", "nd", "rd"];
|
||||
|
||||
export function getOrdinalNumberString(number: number): string {
|
||||
const lastTwo = number % 100;
|
||||
const suffix =
|
||||
suffixes[(lastTwo - 20) % 10] || suffixes[lastTwo] || suffixes[0];
|
||||
return `${number}${suffix}`;
|
||||
}
|
||||
|
||||
export function mapRange(
|
||||
value: number,
|
||||
inMin: number,
|
||||
inMax: number,
|
||||
outMin: number,
|
||||
outMax: number
|
||||
): number {
|
||||
return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin;
|
||||
}
|
||||
|
|
|
@ -1,13 +1,10 @@
|
|||
import { v4 } from "uuid";
|
||||
|
||||
type MonkeyMailOptions = Partial<Omit<MonkeyMailWithTemplate, "id" | "read">>;
|
||||
export interface MonkeyMailWithTemplate extends MonkeyTypes.MonkeyMail {
|
||||
getTemplate?: (user: MonkeyTypes.User) => MonkeyMailOptions;
|
||||
}
|
||||
type MonkeyMailOptions = Partial<Omit<MonkeyTypes.MonkeyMail, "id" | "read">>;
|
||||
|
||||
export function buildMonkeyMail(
|
||||
options: MonkeyMailOptions
|
||||
): MonkeyMailWithTemplate {
|
||||
): MonkeyTypes.MonkeyMail {
|
||||
return {
|
||||
id: v4(),
|
||||
subject: options.subject || "",
|
||||
|
@ -15,6 +12,5 @@ export function buildMonkeyMail(
|
|||
timestamp: options.timestamp || Date.now(),
|
||||
read: false,
|
||||
rewards: options.rewards || [],
|
||||
getTemplate: options.getTemplate,
|
||||
};
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue