Add daily leaderboards (#3023) bruception

* Setup daily leaderboards backend (#2987) bruception

* Setup daily leaderboards backend

* Add enabled checks

* Consistent naming

* Add initial unit tests

* Use more flexible daily leaderboard rule logic

* Fix seed rule

* Add LRU Cache + Rank Calculation

* Use native functions

* Optional daily leaderboard rank

* Proper status code for invalid lb mode

* Add result criteria check

* Make daily leaderboard cache size configurable

* Add Ape endpoint for daily leaderboard (#2997)

* support for switching to viewing daily lbs

* test

* buttons

* only checking daily if user has more than 2 hours typed

* updated structure

* setting rank if its undefined

* only when daily

* storing uid

* fixed media queries

* Daily leaderboards pagination (#3006)

* Pagination

* Remove with scores

* Add daily leaderboard rank (#3014) Bruception

* Add daily leaderboard rank

* Remove unused import

* Use object instead

* Add client logic

* Add limit checks

* Announce top daily leaderboard results (#3017)

* Add rank in daily leaderboard results (#3022)

* not showing lb memory and top % on daily

* Fix rank pagination

* Actual fix

* showing new rank

Co-authored-by: Miodec <bartnikjack@gmail.com>
This commit is contained in:
Bruce Berrios 2022-05-26 10:30:11 -04:00 committed by GitHub
parent 66c912e817
commit 0ef52ed9da
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 1073 additions and 140 deletions

View file

@ -0,0 +1,22 @@
import { ObjectId } from "mongodb";
import { addApeKey } from "../../src/dal/ape-keys";
describe("ApeKeysDal", () => {
it("should be able to add a new ape key", async () => {
const apeKey = {
_id: new ObjectId(),
uid: "123",
name: "test",
hash: "12345",
createdOn: Date.now(),
modifiedOn: Date.now(),
lastUsedOn: Date.now(),
useCount: 0,
enabled: true,
};
const apeKeyId = await addApeKey(apeKey);
expect(apeKeyId).toBe(apeKey._id.toHexString());
});
});

View file

@ -0,0 +1,93 @@
import {
initializeDailyLeaderboardsCache,
getDailyLeaderboard,
} from "../../src/utils/daily-leaderboards";
const dailyLeaderboardsConfig = {
enabled: true,
maxResults: 3,
leaderboardExpirationTimeInDays: 1,
validModeRules: [
{
language: "(english|spanish)",
mode: "time",
mode2: "(15|60)",
},
{
language: "french",
mode: "words",
mode2: "\\d+",
},
],
dailyLeaderboardCacheSize: 3,
topResultsToAnnounce: 3,
};
describe("Daily Leaderboards", () => {
it("should properly handle valid and invalid modes", () => {
initializeDailyLeaderboardsCache(dailyLeaderboardsConfig);
const modeCases = [
{
case: {
language: "english",
mode: "time",
mode2: "60",
},
expected: true,
},
{
case: {
language: "spanish",
mode: "time",
mode2: "15",
},
expected: true,
},
{
case: {
language: "english",
mode: "time",
mode2: "600",
},
expected: false,
},
{
case: {
language: "spanish",
mode: "words",
mode2: "150",
},
expected: false,
},
{
case: {
language: "french",
mode: "time",
mode2: "600",
},
expected: false,
},
{
case: {
language: "french",
mode: "words",
mode2: "100",
},
expected: true,
},
];
modeCases.forEach(({ case: { language, mode, mode2 }, expected }) => {
const result = getDailyLeaderboard(
language,
mode,
mode2,
dailyLeaderboardsConfig
);
expect(!!result).toBe(expected);
});
});
// TODO: Setup Redis mock and test the rest of this
});

View file

@ -0,0 +1,40 @@
import _ from "lodash";
import * as misc from "../../src/utils/misc";
describe("Misc Utils", () => {
it("getCurrentDayTimestamp", () => {
Date.now = jest.fn(() => 1652743381);
const currentDay = misc.getCurrentDayTimestamp();
expect(currentDay).toBe(1641600000);
});
it("matchesAPattern", () => {
const testCases = {
"eng.*": {
cases: ["english", "aenglish", "en", "eng"],
expected: [true, false, false, true],
},
"\\d+": {
cases: ["b", "2", "331", "1a"],
expected: [false, true, true, false],
},
"(hi|hello)": {
cases: ["hello", "hi", "hillo", "hi hello"],
expected: [true, true, false, false],
},
".+": {
cases: ["a2", "b2", "c1", ""],
expected: [true, true, true, false],
},
};
_.each(testCases, (testCase, pattern) => {
const { cases, expected } = testCase;
_.each(cases, (caseValue, index) => {
expect(misc.matchesAPattern(caseValue, pattern)).toBe(expected[index]);
});
});
});
});

View file

@ -22,6 +22,7 @@
"ioredis": "4.28.5",
"joi": "17.6.0",
"lodash": "4.17.21",
"lru-cache": "7.10.1",
"mongodb": "4.4.0",
"node-fetch": "2.6.7",
"nodemon": "2.0.7",
@ -1276,6 +1277,17 @@
"node-pre-gyp": "bin/node-pre-gyp"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/nopt": {
"version": "5.0.0",
"license": "ISC",
@ -3597,6 +3609,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/fastify/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/fastify/node_modules/semver": {
"version": "7.3.5",
"dev": true,
@ -4058,6 +4082,18 @@
"node": ">=10"
}
},
"node_modules/google-auth-library/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"optional": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/google-gax": {
"version": "2.30.0",
"license": "Apache-2.0",
@ -5274,6 +5310,18 @@
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/jest-snapshot/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/jest-snapshot/node_modules/semver": {
"version": "7.3.7",
"dev": true,
@ -5930,13 +5978,11 @@
}
},
"node_modules/lru-cache": {
"version": "6.0.0",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"version": "7.10.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.10.1.tgz",
"integrity": "sha512-BQuhQxPuRl79J5zSXRP+uNzPOyZw2oFI9JLRQ80XswSvg21KMKNtQza9eF42rfI/3Z40RvzBdXgziEkudzjo8A==",
"engines": {
"node": ">=10"
"node": ">=12"
}
},
"node_modules/lru-memoizer": {
@ -6299,6 +6345,18 @@
"node": ">=0.10"
}
},
"node_modules/mongodb-memory-server-core/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/mongodb-memory-server-core/node_modules/mongodb": {
"version": "3.7.3",
"dev": true,
@ -7895,6 +7953,18 @@
"node": ">= 6"
}
},
"node_modules/superagent/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/superagent/node_modules/mime": {
"version": "2.6.0",
"dev": true,
@ -8313,6 +8383,18 @@
}
}
},
"node_modules/ts-jest/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/ts-jest/node_modules/semver": {
"version": "7.3.7",
"dev": true,
@ -9808,6 +9890,14 @@
"tar": "^6.1.11"
},
"dependencies": {
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"requires": {
"yallist": "^4.0.0"
}
},
"nopt": {
"version": "5.0.0",
"requires": {
@ -11396,6 +11486,15 @@
"tiny-lru": "^8.0.1"
},
"dependencies": {
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"requires": {
"yallist": "^4.0.0"
}
},
"semver": {
"version": "7.3.5",
"dev": true,
@ -11703,6 +11802,17 @@
"gtoken": "^5.0.4",
"jws": "^4.0.0",
"lru-cache": "^6.0.0"
},
"dependencies": {
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"optional": true,
"requires": {
"yallist": "^4.0.0"
}
}
}
},
"google-gax": {
@ -12496,6 +12606,15 @@
"semver": "^7.3.2"
},
"dependencies": {
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"requires": {
"yallist": "^4.0.0"
}
},
"semver": {
"version": "7.3.7",
"dev": true,
@ -12961,10 +13080,9 @@
"version": "1.0.1"
},
"lru-cache": {
"version": "6.0.0",
"requires": {
"yallist": "^4.0.0"
}
"version": "7.10.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.10.1.tgz",
"integrity": "sha512-BQuhQxPuRl79J5zSXRP+uNzPOyZw2oFI9JLRQ80XswSvg21KMKNtQza9eF42rfI/3Z40RvzBdXgziEkudzjo8A=="
},
"lru-memoizer": {
"version": "2.1.4",
@ -13186,6 +13304,15 @@
"version": "1.5.1",
"dev": true
},
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"requires": {
"yallist": "^4.0.0"
}
},
"mongodb": {
"version": "3.7.3",
"dev": true,
@ -14189,6 +14316,15 @@
"mime-types": "^2.1.12"
}
},
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"requires": {
"yallist": "^4.0.0"
}
},
"mime": {
"version": "2.6.0",
"dev": true
@ -14433,6 +14569,15 @@
"yargs-parser": "20.x"
},
"dependencies": {
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"requires": {
"yallist": "^4.0.0"
}
},
"semver": {
"version": "7.3.7",
"dev": true,

View file

@ -29,6 +29,7 @@
"ioredis": "4.28.5",
"joi": "17.6.0",
"lodash": "4.17.21",
"lru-cache": "7.10.1",
"mongodb": "4.4.0",
"node-fetch": "2.6.7",
"nodemon": "2.0.7",

View file

@ -0,0 +1,38 @@
local redis_call = redis.call
local leaderboard_scores_key, leaderboard_results_key = KEYS[1], KEYS[2]
local max_results = tonumber(ARGV[1])
local leaderboard_expiration_time = ARGV[2]
local user_id = ARGV[3]
local result_score = ARGV[4]
local result_data = ARGV[5]
local number_of_results_changed = redis_call('ZADD', leaderboard_scores_key, 'GT', 'CH', result_score, user_id)
if (number_of_results_changed == 1) then
redis_call('HSET', leaderboard_results_key, user_id, result_data)
end
local number_of_results = redis_call('ZCARD', leaderboard_scores_key)
local removed_user_id = nil
if (number_of_results > max_results) then
local user_with_lowest_score = redis_call('ZPOPMIN', leaderboard_scores_key)
removed_user_id = user_with_lowest_score[1]
if (removed_user_id ~= nil) then
redis_call('HDEL', leaderboard_results_key, removed_user_id)
end
end
if (number_of_results == 1) then -- Indicates that this is the first score of the day, set the leaderboard keys to expire at specified time
redis_call('EXPIREAT', leaderboard_scores_key, leaderboard_expiration_time)
redis_call('EXPIREAT', leaderboard_results_key, leaderboard_expiration_time)
end
if (number_of_results_changed == 1 and removed_user_id ~= user_id) then
return redis_call('ZREVRANK', leaderboard_scores_key, user_id)
end
return nil

View file

@ -0,0 +1,18 @@
local redis_call = redis.call
local leaderboard_scores_key, leaderboard_results_key = KEYS[1], KEYS[2]
local min_rank = tonumber(ARGV[1])
local max_rank = tonumber(ARGV[2])
local results = {}
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 (result_data ~= nil) then
results[#results + 1] = result_data
end
end
return results

View file

@ -73,6 +73,12 @@ beforeEach(async () => {
}
});
const realDateNow = Date.now;
afterEach(() => {
Date.now = realDateNow;
});
afterAll(async () => {
await connection.close();
});

View file

@ -2,6 +2,7 @@ import _ from "lodash";
import { MonkeyResponse } from "../../utils/monkey-response";
import * as LeaderboardsDAL from "../../dal/leaderboards";
import MonkeyError from "../../utils/error";
import * as DailyLeaderboards from "../../utils/daily-leaderboards";
export async function getLeaderboard(
req: MonkeyTypes.Request
@ -67,3 +68,55 @@ export async function getRankFromLeaderboard(
return new MonkeyResponse("Rank retrieved", data);
}
function getDailyLeaderboardWithError(
req: MonkeyTypes.Request
): DailyLeaderboards.DailyLeaderboard {
const { language, mode, mode2 } = req.query;
const dailyLeaderboard = DailyLeaderboards.getDailyLeaderboard(
language as string,
mode as string,
mode2 as string,
req.ctx.configuration.dailyLeaderboards
);
if (!dailyLeaderboard) {
throw new MonkeyError(404, "There is no daily leaderboard for this mode");
}
return dailyLeaderboard;
}
export async function getDailyLeaderboard(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
const { skip = 0, limit = 50 } = req.query;
const dailyLeaderboard = getDailyLeaderboardWithError(req);
const minRank = parseInt(skip as string, 10);
const maxRank = minRank + parseInt(limit as string, 10) - 1;
const topResults = await dailyLeaderboard.getResults(
minRank,
maxRank,
req.ctx.configuration.dailyLeaderboards
);
return new MonkeyResponse("Daily leaderboard retrieved", topResults);
}
export async function getDailyLeaderboardRank(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
const { uid } = req.ctx.decodedToken;
const dailyLeaderboard = getDailyLeaderboardWithError(req);
const rank = await dailyLeaderboard.getRank(
uid,
req.ctx.configuration.dailyLeaderboards
);
return new MonkeyResponse("Daily leaderboard rank retrieved", rank);
}

View file

@ -23,6 +23,7 @@ import {
import MonkeyStatusCodes from "../../constants/monkey-status-codes";
import { incrementResult } from "../../utils/prometheus";
import * as George from "../../tasks/george";
import { getDailyLeaderboard } from "../../utils/daily-leaderboards";
try {
if (anticheatImplemented() === false) throw new Error("undefined");
@ -76,6 +77,13 @@ export async function updateTags(
return new MonkeyResponse("Result tags updated");
}
interface AddResultData {
isPb: boolean;
tagPbs: any[];
insertedId: ObjectId;
dailyLeaderboardRank?: number;
}
export async function addResult(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
@ -296,6 +304,37 @@ export async function addResult(
updateTypingStats(uid, result.restartCount, tt);
PublicStatsDAL.updateStats(result.restartCount, tt);
const dailyLeaderboardsConfig = req.ctx.configuration.dailyLeaderboards;
const dailyLeaderboard = getDailyLeaderboard(
result.language,
result.mode,
result.mode2,
dailyLeaderboardsConfig
);
let dailyLeaderboardRank = -1;
const { funbox, bailedOut } = result;
const validResultCriteria =
(funbox === "none" || funbox === "plus_one" || funbox === "plus_two") &&
!bailedOut &&
(user.timeTyping ?? 0) > 7200;
if (dailyLeaderboard && validResultCriteria) {
dailyLeaderboardRank = await dailyLeaderboard.addResult(
{
name: user.name,
wpm: result.wpm,
raw: result.rawWpm,
acc: result.acc,
consistency: result.consistency,
timestamp: result.timestamp,
uid,
},
dailyLeaderboardsConfig
);
}
if (result.bailedOut === false) delete result.bailedOut;
if (result.blindMode === false) delete result.blindMode;
if (result.lazyMode === false) delete result.lazyMode;
@ -322,13 +361,16 @@ export async function addResult(
);
}
const data = {
const data: AddResultData = {
isPb,
name: result.name,
tagPbs,
insertedId: addedResult.insertedId,
};
if (dailyLeaderboardRank !== -1) {
data.dailyLeaderboardRank = dailyLeaderboardRank;
}
incrementResult(result);
return new MonkeyResponse("Result saved", data);

View file

@ -4,22 +4,39 @@ import * as RateLimit from "../../middlewares/rate-limit";
import apeRateLimit from "../../middlewares/ape-rate-limit";
import { authenticateRequest } from "../../middlewares/auth";
import * as LeaderboardController from "../controllers/leaderboard";
import { asyncHandler, validateRequest } from "../../middlewares/api-utils";
import {
asyncHandler,
validateRequest,
validateConfiguration,
} from "../../middlewares/api-utils";
const BASE_LEADERBOARD_VALIDATION_SCHEMA = {
language: joi.string().required(),
mode: joi.string().required(),
mode2: joi.string().required(),
};
const LEADERBOARD_VALIDATION_SCHEMA_WITH_LIMIT = {
...BASE_LEADERBOARD_VALIDATION_SCHEMA,
skip: joi.number().min(0),
limit: joi.number().min(0).max(50),
};
const router = Router();
const requireDailyLeaderboardsEnabled = validateConfiguration({
criteria: (configuration) => {
return configuration.dailyLeaderboards.enabled;
},
invalidMessage: "Daily leaderboards are not available at this time.",
});
router.get(
"/",
RateLimit.leaderboardsGet,
authenticateRequest({ isPublic: true, acceptApeKeys: true }),
validateRequest({
query: {
language: joi.string().required(),
mode: joi.string().required(),
mode2: joi.string().required(),
skip: joi.number().min(0),
limit: joi.number().min(0).max(50),
},
query: LEADERBOARD_VALIDATION_SCHEMA_WITH_LIMIT,
}),
asyncHandler(LeaderboardController.getLeaderboard)
);
@ -30,13 +47,31 @@ router.get(
authenticateRequest({ acceptApeKeys: true }),
apeRateLimit,
validateRequest({
query: {
language: joi.string().required(),
mode: joi.string().required(),
mode2: joi.string().required(),
},
query: BASE_LEADERBOARD_VALIDATION_SCHEMA,
}),
asyncHandler(LeaderboardController.getRankFromLeaderboard)
);
router.get(
"/daily",
requireDailyLeaderboardsEnabled,
RateLimit.leaderboardsGet,
authenticateRequest({ isPublic: true }),
validateRequest({
query: LEADERBOARD_VALIDATION_SCHEMA_WITH_LIMIT,
}),
asyncHandler(LeaderboardController.getDailyLeaderboard)
);
router.get(
"/daily/rank",
requireDailyLeaderboardsEnabled,
RateLimit.leaderboardsGet,
authenticateRequest(),
validateRequest({
query: BASE_LEADERBOARD_VALIDATION_SCHEMA,
}),
asyncHandler(LeaderboardController.getDailyLeaderboardRank)
);
export default router;

View file

@ -27,13 +27,22 @@ const BASE_CONFIGURATION: MonkeyTypes.Configuration = {
enabled: false,
},
favoriteQuotes: {
maxFavorites: 100,
maxFavorites: 0,
},
autoBan: {
enabled: false,
maxCount: 5,
maxHours: 1,
},
dailyLeaderboards: {
enabled: false,
maxResults: 0,
leaderboardExpirationTimeInDays: 0,
validModeRules: [],
// 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.
},
};
export default BASE_CONFIGURATION;

View file

@ -1,9 +1,28 @@
import fs from "fs";
import _ from "lodash";
import { join } from "path";
import IORedis from "ioredis";
import Logger from "../utils/logger";
let connection: IORedis.Redis;
let connected = false;
const REDIS_SCRIPTS_DIRECTORY_PATH = join(__dirname, "../../redis-scripts");
function loadScripts(client: IORedis.Redis): void {
const scriptFiles = fs.readdirSync(REDIS_SCRIPTS_DIRECTORY_PATH);
scriptFiles.forEach((scriptFile) => {
const scriptPath = join(REDIS_SCRIPTS_DIRECTORY_PATH, scriptFile);
const scriptSource = fs.readFileSync(scriptPath, "utf-8");
const scriptName = _.camelCase(scriptFile.split(".")[0]);
client.defineCommand(scriptName, {
lua: scriptSource,
});
});
}
export async function connect(): Promise<void> {
if (connected) {
return;
@ -27,15 +46,19 @@ export async function connect(): Promise<void> {
try {
await connection.connect();
Logger.info("Loading custom redis scripts...");
loadScripts(connection);
connected = true;
} catch (error) {
Logger.error(error.message);
if (MODE === "dev") {
await connection.quit();
Logger.warning(
`Failed to connect to redis. Continuing in dev mode, running without redis.`
);
} else {
Logger.error(error.message);
Logger.error(
"Failed to connect to redis. Exiting with exit status code 1."
);

View file

@ -0,0 +1,67 @@
import { CronJob } from "cron";
import { getCurrentDayTimestamp } from "../utils/misc";
import { getCachedConfiguration } from "../init/configuration";
import { DailyLeaderboard } from "../utils/daily-leaderboards";
import { announceDailyLeaderboardTopResults } from "../tasks/george";
const CRON_SCHEDULE = "1 0 * * *"; // At 00:01.
const ONE_DAY_IN_MILLISECONDS = 24 * 60 * 60 * 1000;
const leaderboardsToAnnounce = [
{
language: "english",
mode: "time",
mode2: "15",
},
{
language: "english",
mode: "time",
mode2: "60",
},
];
async function announceDailyLeaderboard(
language: string,
mode: string,
mode2: string,
dailyLeaderboardsConfig: MonkeyTypes.Configuration["dailyLeaderboards"]
): Promise<void> {
const yesterday = getCurrentDayTimestamp() - ONE_DAY_IN_MILLISECONDS;
const dailyLeaderboard = new DailyLeaderboard(
language,
mode,
mode2,
yesterday
);
const topResults = await dailyLeaderboard.getResults(
0,
dailyLeaderboardsConfig.topResultsToAnnounce - 1,
dailyLeaderboardsConfig
);
if (topResults.length === 0) {
return;
}
const leaderboardId = `${mode} ${mode2} ${language}`;
await announceDailyLeaderboardTopResults(
leaderboardId,
yesterday,
topResults
);
}
async function announceDailyLeaderboards(): Promise<void> {
const { dailyLeaderboards } = await getCachedConfiguration();
if (!dailyLeaderboards.enabled) {
return;
}
await Promise.allSettled(
leaderboardsToAnnounce.map(({ language, mode, mode2 }) => {
return announceDailyLeaderboard(language, mode, mode2, dailyLeaderboards);
})
);
}
export default new CronJob(CRON_SCHEDULE, announceDailyLeaderboards);

View file

@ -1,4 +1,5 @@
import updateLeaderboards from "./update-leaderboards";
import deleteOldLogs from "./delete-old-logs";
import announceDailyLeaderboards from "./announce-daily-leaderboards";
export default [updateLeaderboards, deleteOldLogs];
export default [updateLeaderboards, deleteOldLogs, announceDailyLeaderboards];

View file

@ -5,6 +5,7 @@ import serviceAccount from "./credentials/serviceAccountKey.json"; // eslint-dis
import * as db from "./init/db";
import jobs from "./jobs";
import { getLiveConfiguration } from "./init/configuration";
import { initializeDailyLeaderboardsCache } from "./utils/daily-leaderboards";
import app from "./app";
import { Server } from "http";
import { version } from "./version";
@ -28,7 +29,7 @@ async function bootServer(port: number): Promise<Server> {
Logger.success("Firebase app initialized");
Logger.info("Fetching live configuration...");
await getLiveConfiguration();
const liveConfiguration = await getLiveConfiguration();
Logger.success("Live configuration fetched");
Logger.info("Connecting to redis...");
@ -40,6 +41,8 @@ async function bootServer(port: number): Promise<Server> {
Logger.info("Initializing task queues...");
initJobQueue(RedisClient.getConnection());
Logger.success("Task queues initialized");
initializeDailyLeaderboardsCache(liveConfiguration.dailyLeaderboards);
}
Logger.info("Starting cron jobs...");

View file

@ -3,19 +3,14 @@ import { Queue, QueueScheduler } from "bullmq";
const QUEUE_NAME = "george-tasks";
type GeorgeTaskArgument = string | number;
interface GeorgeTask {
name: string;
args: GeorgeTaskArgument[];
args: any[];
}
function buildGeorgeTask(
task: string,
taskArgs: GeorgeTaskArgument[]
): GeorgeTask {
function buildGeorgeTask(taskName: string, taskArgs: any[]): GeorgeTask {
return {
name: task,
name: taskName,
args: taskArgs,
};
}
@ -68,46 +63,49 @@ export async function updateDiscordRole(
discordId: string,
wpm: number
): Promise<void> {
const task = "updateRole";
const updateDiscordRoleTask = buildGeorgeTask(task, [discordId, wpm]);
await addToQueue(task, updateDiscordRoleTask);
const taskName = "updateRole";
const updateDiscordRoleTask = buildGeorgeTask(taskName, [discordId, wpm]);
await addToQueue(taskName, updateDiscordRoleTask);
}
export async function linkDiscord(
discordId: string,
uid: string
): Promise<void> {
const task = "linkDiscord";
const linkDiscordTask = buildGeorgeTask(task, [discordId, uid]);
await addToQueue(task, linkDiscordTask);
const taskName = "linkDiscord";
const linkDiscordTask = buildGeorgeTask(taskName, [discordId, uid]);
await addToQueue(taskName, linkDiscordTask);
}
export async function unlinkDiscord(
discordId: string,
uid: string
): Promise<void> {
const task = "unlinkDiscord";
const unlinkDiscordTask = buildGeorgeTask(task, [discordId, uid]);
await addToQueue(task, unlinkDiscordTask);
const taskName = "unlinkDiscord";
const unlinkDiscordTask = buildGeorgeTask(taskName, [discordId, uid]);
await addToQueue(taskName, unlinkDiscordTask);
}
export async function awardChallenge(
discordId: string,
challengeName: string
): Promise<void> {
const task = "awardChallenge";
const awardChallengeTask = buildGeorgeTask(task, [discordId, challengeName]);
await addToQueue(task, awardChallengeTask);
const taskName = "awardChallenge";
const awardChallengeTask = buildGeorgeTask(taskName, [
discordId,
challengeName,
]);
await addToQueue(taskName, awardChallengeTask);
}
export async function announceLeaderboardUpdate(
newRecords: any[],
leaderboardId: string
): Promise<void> {
const task = "announceLeaderboardUpdate";
const taskName = "announceLeaderboardUpdate";
const leaderboardUpdateTasks = newRecords.map((record) => {
const taskData = buildGeorgeTask(task, [
const taskData = buildGeorgeTask(taskName, [
record.discordId ?? record.name,
record.rank,
leaderboardId,
@ -118,10 +116,25 @@ export async function announceLeaderboardUpdate(
]);
return {
name: task,
name: taskName,
data: taskData,
};
});
await addToQueueBulk(leaderboardUpdateTasks);
}
export async function announceDailyLeaderboardTopResults(
leaderboardId: string,
leaderboardTimestamp: number,
topResults: any[]
): Promise<void> {
const taskName = "announceDailyLeaderboardTopResults";
const dailyLeaderboardTopResultsTask = buildGeorgeTask(taskName, [
taskName,
[leaderboardId, leaderboardTimestamp, topResults],
]);
await addToQueue(taskName, dailyLeaderboardTopResultsTask);
}

View file

@ -3,6 +3,12 @@ type ObjectId = import("mongodb").ObjectId;
type ExpressRequest = import("express").Request;
declare namespace MonkeyTypes {
interface ValidModeRule {
language: string;
mode: string;
mode2: string;
}
interface Configuration {
maintenance: boolean;
quoteReport: {
@ -34,6 +40,14 @@ declare namespace MonkeyTypes {
maxCount: number;
maxHours: number;
};
dailyLeaderboards: {
enabled: boolean;
leaderboardExpirationTimeInDays: number;
maxResults: number;
validModeRules: ValidModeRule[];
dailyLeaderboardCacheSize: number;
topResultsToAnnounce: number;
};
}
interface DecodedToken {

View file

@ -0,0 +1,213 @@
import _ from "lodash";
import LRUCache from "lru-cache";
import * as RedisClient from "../init/redis";
import { getCurrentDayTimestamp, matchesAPattern } from "./misc";
interface DailyLeaderboardEntry {
uid: string;
name: string;
wpm: number;
raw: number;
acc: number;
consistency: number;
timestamp: number;
rank?: number;
count?: number;
}
const dailyLeaderboardNamespace = "monkeytypes:dailyleaderboard";
const scoresNamespace = `${dailyLeaderboardNamespace}:scores`;
const resultsNamespace = `${dailyLeaderboardNamespace}:results`;
function compareDailyLeaderboardEntries(
a: DailyLeaderboardEntry,
b: DailyLeaderboardEntry
): number {
if (a.wpm !== b.wpm) {
return b.wpm - a.wpm;
}
if (a.acc !== b.acc) {
return b.acc - a.acc;
}
return a.timestamp - b.timestamp;
}
export class DailyLeaderboard {
private leaderboardResultsKeyName: string;
private leaderboardScoresKeyName: string;
private leaderboardModeKey: string;
private customTime: number;
constructor(language: string, mode: string, mode2: string, customTime = -1) {
this.leaderboardModeKey = `${language}:${mode}:${mode2}`;
this.leaderboardResultsKeyName = `${resultsNamespace}:${this.leaderboardModeKey}`;
this.leaderboardScoresKeyName = `${scoresNamespace}:${this.leaderboardModeKey}`;
this.customTime = customTime;
}
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: DailyLeaderboardEntry,
dailyLeaderboardsConfig: MonkeyTypes.Configuration["dailyLeaderboards"]
): Promise<number> {
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
);
// @ts-ignore
const rank = await connection.addResult(
2,
leaderboardScoresKey,
leaderboardResultsKey,
maxResults,
leaderboardExpirationTimeInSeconds,
entry.uid,
entry.wpm,
JSON.stringify(entry)
);
if (rank === null) {
return -1;
}
return rank + 1;
}
public async getResults(
minRank: number,
maxRank: number,
dailyLeaderboardsConfig: MonkeyTypes.Configuration["dailyLeaderboards"]
): Promise<DailyLeaderboardEntry[]> {
const connection = RedisClient.getConnection();
if (!connection || !dailyLeaderboardsConfig.enabled) {
return [];
}
const { leaderboardScoresKey, leaderboardResultsKey } =
this.getTodaysLeaderboardKeys();
// @ts-ignore
const results: string[] = await connection.getResults(
2,
leaderboardScoresKey,
leaderboardResultsKey,
minRank,
maxRank
);
const normalizedResults: DailyLeaderboardEntry[] = results
.map((result) => JSON.parse(result))
.sort(compareDailyLeaderboardEntries);
const resultsWithRanks: DailyLeaderboardEntry[] = normalizedResults.map(
(result, index) => ({
...result,
rank: minRank + index + 1,
})
);
return resultsWithRanks;
}
public async getRank(
uid: string,
dailyLeaderboardsConfig: MonkeyTypes.Configuration["dailyLeaderboards"]
): Promise<DailyLeaderboardEntry | null> {
const connection = RedisClient.getConnection();
if (!connection || !dailyLeaderboardsConfig.enabled) {
return null;
}
const { leaderboardScoresKey, leaderboardResultsKey } =
this.getTodaysLeaderboardKeys();
const [[, rank], [, count], [, result]] = await connection
.multi()
.zrevrank(leaderboardScoresKey, uid)
.zcard(leaderboardScoresKey)
.hget(leaderboardResultsKey, uid)
.exec();
if (rank === null) {
return null;
}
return {
rank: rank + 1,
count: count ?? 0,
...JSON.parse(result ?? "null"),
};
}
}
let DAILY_LEADERBOARDS: LRUCache<string, DailyLeaderboard>;
export function initializeDailyLeaderboardsCache(
configuration: MonkeyTypes.Configuration["dailyLeaderboards"]
): void {
const { dailyLeaderboardCacheSize } = configuration;
DAILY_LEADERBOARDS = new LRUCache({
max: dailyLeaderboardCacheSize,
});
}
export function getDailyLeaderboard(
language: string,
mode: string,
mode2: string,
dailyLeaderboardsConfig: MonkeyTypes.Configuration["dailyLeaderboards"]
): DailyLeaderboard | null {
const { validModeRules, enabled } = dailyLeaderboardsConfig;
const isValidMode = validModeRules.some((rule) => {
const matchesLanguage = matchesAPattern(language, rule.language);
const matchesMode = matchesAPattern(mode, rule.mode);
const matchesMode2 = matchesAPattern(mode2, rule.mode2);
return matchesLanguage && matchesMode && matchesMode2;
});
if (!enabled || !isValidMode || !DAILY_LEADERBOARDS) {
return null;
}
const key = `${language}:${mode}:${mode2}`;
if (!DAILY_LEADERBOARDS.has(key)) {
const dailyLeaderboard = new DailyLeaderboard(language, mode, mode2);
DAILY_LEADERBOARDS.set(key, dailyLeaderboard);
}
return DAILY_LEADERBOARDS.get(key) ?? null;
}

View file

@ -80,3 +80,13 @@ export function padNumbers(
number.toString().padStart(maxLength, fillString)
);
}
export function getCurrentDayTimestamp(): number {
const currentTime = Date.now();
return currentTime - (currentTime % 86400000);
}
export function matchesAPattern(text: string, pattern: string): boolean {
const regex = new RegExp(`^${pattern}$`);
return regex.test(text);
}

View file

@ -9,7 +9,7 @@
padding: 1rem;
display: grid;
gap: 1rem 0;
grid-template-rows: 2rem auto;
grid-template-rows: auto auto;
grid-template-areas:
"title buttons"
"tables tables";
@ -18,23 +18,29 @@
.leaderboardsTop {
width: 200%;
min-width: 100%;
display: flex;
display: grid;
align-items: center;
justify-content: space-between;
grid-template-areas:
"title buttons"
"subtitle buttons";
.buttonGroup .button {
padding: 0.4rem 2.18rem;
.buttons {
grid-area: buttons;
.buttonGroup .button {
padding: 0.4rem 2.18rem;
}
}
}
.mainTitle {
font-size: 2.5rem;
line-height: 2.5rem;
grid-area: title;
}
.subTitle {
color: var(--sub-color);
grid-area: subtitle;
}
.title {
@ -177,8 +183,9 @@
.buttonGroup {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
gap: 1rem;
grid-area: 1/2;
grid-area: buttons;
}
}
}

View file

@ -1,23 +1,15 @@
@media only screen and (max-width: 1250px) {
#leaderboardsWrapper #leaderboards {
grid-template-rows: 1.5rem auto;
padding: 1rem;
gap: 1rem;
.mainTitle {
font-size: 2rem;
line-height: 2rem;
}
.title {
font-size: 1rem;
}
.leaderboardsTop {
.buttonGroup {
gap: 0.1rem !important;
.button {
padding: 0.4rem !important;
font-size: 0.7rem !important;
}
grid-auto-flow: row;
gap: 0.5rem;
}
}
.tables table {
@ -98,14 +90,12 @@
}
@media only screen and (max-width: 900px) {
// #leaderboards {
// .mainTitle {
// font-size: 1.5rem !important;
// line-height: 1.5rem !important;
// }
// }
#leaderboards {
.mainTitle {
font-size: 1.5rem !important;
}
}
#bannerCenter .banner .container {
grid-template-columns: 1fr auto;
.image {
display: none;
}
@ -310,7 +300,6 @@
#leaderboardsWrapper {
#leaderboards {
grid-template-rows: 2.5rem auto;
.leaderboardsTop {
flex-direction: column;
align-items: baseline;

View file

@ -4,35 +4,37 @@ export default function getLeaderboardsEndpoints(
apeClient: Ape.Client
): Ape.Endpoints["leaderboards"] {
async function get(
language: string,
mode: MonkeyTypes.Mode,
mode2: string | number,
skip = 0,
limit = 50
query: Ape.EndpointTypes.LeadeboardQueryWithPagination
): Ape.EndpointData {
const { language, mode, mode2, isDaily, skip = 0, limit = 50 } = query;
const searchQuery = {
language,
mode,
mode2,
skip,
skip: Math.max(skip, 0),
limit: Math.max(Math.min(limit, 50), 0),
};
return await apeClient.get(BASE_PATH, { searchQuery });
const endpointPath = `${BASE_PATH}/${isDaily ? "daily" : ""}`;
return await apeClient.get(endpointPath, { searchQuery });
}
async function getRank(
language: string,
mode: MonkeyTypes.Mode,
mode2: string | number
query: Ape.EndpointTypes.LeaderboardQuery
): Ape.EndpointData {
const { language, mode, mode2, isDaily } = query;
const searchQuery = {
language,
mode,
mode2,
};
return await apeClient.get(`${BASE_PATH}/rank`, { searchQuery });
const endpointPath = `${BASE_PATH}${isDaily ? "/daily" : ""}/rank`;
return await apeClient.get(endpointPath, { searchQuery });
}
return { get, getRank };

View file

@ -34,6 +34,20 @@ declare namespace Ape {
type EndpointData = Promise<Response>;
type Endpoint = () => EndpointData;
declare namespace EndpointTypes {
interface LeaderboardQuery {
language: string;
mode: MonkeyTypes.Mode;
mode2: string | number;
isDaily?: boolean;
}
interface LeadeboardQueryWithPagination extends LeaderboardQuery {
skip?: number;
limit?: number;
}
}
interface Endpoints {
configs: {
get: Endpoint;
@ -41,18 +55,8 @@ declare namespace Ape {
};
leaderboards: {
get: (
language: string,
mode: MonkeyTypes.Mode,
mode2: string | number,
skip: number,
limit?: number
) => EndpointData;
getRank: (
language: string,
mode: MonkeyTypes.Mode,
mode2: string | number
) => EndpointData;
get: (query: EndpointTypes.LeadeboardQueryWithPagination) => EndpointData;
getRank: (query: EndpointTypes.LeaderboardQuery) => EndpointData;
};
presets: {

View file

@ -5,8 +5,9 @@ import * as Misc from "../utils/misc";
import * as Notifications from "./notifications";
import format from "date-fns/format";
import { Auth } from "../firebase";
import differenceInSeconds from "date-fns/differenceInSeconds";
const currentLeaderboard = "time_15";
let currentTimeRange: "allTime" | "daily" = "allTime";
type LbKey = 15 | 60;
@ -64,17 +65,30 @@ function reset(): void {
function stopTimer(): void {
clearInterval(updateTimer);
updateTimer = undefined;
$("#leaderboards .subTitle").text("Next update in: --:--");
$("#leaderboards .subTitle").text("-");
}
function updateTimerElement(): void {
const date = new Date();
const minutesToNextUpdate = 14 - (date.getMinutes() % 15);
const secondsToNextUpdate = 60 - date.getSeconds();
const totalSeconds = minutesToNextUpdate * 60 + secondsToNextUpdate;
$("#leaderboards .subTitle").text(
"Next update in: " + Misc.secondsToString(totalSeconds, true)
);
if (currentTimeRange === "daily") {
const date = new Date();
date.setUTCHours(0, 0, 0, 0);
date.setDate(date.getDate() + 1);
const dateNow = new Date();
dateNow.setUTCMilliseconds(0);
const diff = differenceInSeconds(date, dateNow);
$("#leaderboards .subTitle").text(
"Next reset in: " + Misc.secondsToString(diff, true)
);
} else {
const date = new Date();
const minutesToNextUpdate = 14 - (date.getMinutes() % 15);
const secondsToNextUpdate = 60 - date.getSeconds();
const totalSeconds = minutesToNextUpdate * 60 + secondsToNextUpdate;
$("#leaderboards .subTitle").text(
"Next update in: " + Misc.secondsToString(totalSeconds, true)
);
}
}
function startTimer(): void {
@ -133,7 +147,7 @@ function updateFooter(lb: LbKey): void {
`);
let toppercent;
if (currentRank[lb]) {
if (currentTimeRange === "allTime" && currentRank[lb]) {
const num = Misc.roundTo2(
(currentRank[lb]["rank"] / (currentRank[lb].count as number)) * 100
);
@ -142,14 +156,16 @@ function updateFooter(lb: LbKey): void {
} else {
toppercent = `Top ${num}%`;
}
toppercent = `<br><span class="sub">${toppercent}</span>`;
}
if (currentRank[lb]) {
const entry = currentRank[lb];
const date = new Date(entry.timestamp);
$(`#leaderboardsWrapper table.${side} tfoot`).html(`
<tr>
<td>${entry.rank}</td>
<td><span class="top">You</span><br><span class="sub">${toppercent}</span></td>
<td><span class="top">You</span>${toppercent ? toppercent : ""}</td>
<td class="alignRight">${(Config.alwaysShowCPM
? entry.wpm * 5
: entry.wpm
@ -171,6 +187,8 @@ function updateFooter(lb: LbKey): void {
}
function checkLbMemory(lb: LbKey): void {
if (currentTimeRange === "daily") return;
let side;
if (lb === 15) {
side = "left";
@ -221,6 +239,13 @@ function fillTable(lb: LbKey, prepend?: number): void {
} else {
side = "right";
}
if (currentData[lb].length === 0) {
$(`#leaderboardsWrapper table.${side} tbody`).html(
"<tr><td colspan='7'>No results found</td></tr>"
);
}
const loggedInUserName = DB.getSnapshot()?.name;
let a = currentData[lb].length - leaderboardSingleLimit;
@ -242,6 +267,11 @@ function fillTable(lb: LbKey, prepend?: number): void {
meClassString = ' class="me"';
}
const date = new Date(entry.timestamp);
if (currentTimeRange === "daily" && !entry.rank) {
entry.rank = i + 1;
}
html += `
<tr ${meClassString}>
<td>${
@ -294,24 +324,39 @@ export function hide(): void {
);
}
async function update(): Promise<void> {
$("#leaderboardsWrapper .buttons .button").removeClass("active");
$(
`#leaderboardsWrapper .buttons .button[board=${currentLeaderboard}]`
).addClass("active");
function updateTitle(): void {
const el = $("#leaderboardsWrapper .mainTitle");
const timeRangeString = currentTimeRange === "daily" ? "Daily" : "All-Time";
el.text(`${timeRangeString} English Leaderboards`);
}
async function update(): Promise<void> {
showLoader(15);
showLoader(60);
const leaderboardRequests = [
Ape.leaderboards.get("english", "time", "15", 0),
Ape.leaderboards.get("english", "time", "60", 0),
];
const timeModes = ["15", "60"];
const leaderboardRequests = timeModes.map((mode2) => {
return Ape.leaderboards.get({
language: "english",
mode: "time",
mode2,
isDaily: currentTimeRange === "daily",
});
});
if (Auth.currentUser) {
leaderboardRequests.push(
Ape.leaderboards.getRank("english", "time", "15"),
Ape.leaderboards.getRank("english", "time", "60")
...timeModes.map((mode2) => {
return Ape.leaderboards.getRank({
language: "english",
mode: "time",
mode2,
isDaily: currentTimeRange === "daily",
});
})
);
}
@ -319,6 +364,8 @@ async function update(): Promise<void> {
const failedResponse = responses.find((response) => response.status !== 200);
if (failedResponse) {
hideLoader(15);
hideLoader(60);
return Notifications.add(
"Failed to load leaderboards: " + failedResponse.message,
-1
@ -346,6 +393,12 @@ async function update(): Promise<void> {
$("#leaderboardsWrapper .leftTableWrapper").removeClass("invisible");
$("#leaderboardsWrapper .rightTableWrapper").removeClass("invisible");
updateTitle();
$("#leaderboardsWrapper .buttons .button").removeClass("active");
$(
`#leaderboardsWrapper .buttonGroup.timeRange .button.` + currentTimeRange
).addClass("active");
}
async function requestMore(lb: LbKey, prepend = false): Promise<void> {
@ -363,13 +416,14 @@ async function requestMore(lb: LbKey, prepend = false): Promise<void> {
skipVal = 0;
}
const response = await Ape.leaderboards.get(
"english",
"time",
lb,
skipVal,
limitVal
);
const response = await Ape.leaderboards.get({
language: "english",
mode: "time",
mode2: lb.toString(),
isDaily: currentTimeRange === "daily",
skip: skipVal,
limit: limitVal,
});
const data: MonkeyTypes.LeaderboardEntry[] = response.data;
if (response.status !== 200 || data.length === 0) {
@ -392,7 +446,13 @@ async function requestMore(lb: LbKey, prepend = false): Promise<void> {
async function requestNew(lb: LbKey, skip: number): Promise<void> {
showLoader(lb);
const response = await Ape.leaderboards.get("english", "time", lb, skip);
const response = await Ape.leaderboards.get({
language: "english",
mode: "time",
mode2: lb.toString(),
isDaily: currentTimeRange === "daily",
skip,
});
const data: MonkeyTypes.LeaderboardEntry[] = response.data;
if (response.status === 503) {
@ -522,7 +582,7 @@ $("#leaderboardsWrapper #leaderboards .leftTableJumpToTop").on(
$("#leaderboardsWrapper #leaderboards .leftTableJumpToMe").on(
"click",
async () => {
if (currentRank[15].rank === undefined) return;
if (!currentRank[15]?.rank) return;
leftScrollEnabled = false;
await requestNew(15, currentRank[15].rank - leaderboardSingleLimit / 2);
const rowHeight = $(
@ -559,7 +619,7 @@ $("#leaderboardsWrapper #leaderboards .rightTableJumpToTop").on(
$("#leaderboardsWrapper #leaderboards .rightTableJumpToMe").on(
"click",
async () => {
if (currentRank[60].rank === undefined) return;
if (!currentRank[60]?.rank) return;
leftScrollEnabled = false;
await requestNew(60, currentRank[60].rank - leaderboardSingleLimit / 2);
const rowHeight = $(
@ -583,6 +643,20 @@ $("#leaderboardsWrapper #leaderboards .rightTableJumpToMe").on(
}
);
$(
"#leaderboardsWrapper #leaderboards .leaderboardsTop .buttonGroup.timeRange .allTime"
).on("click", () => {
currentTimeRange = "allTime";
update();
});
$(
"#leaderboardsWrapper #leaderboards .leaderboardsTop .buttonGroup.timeRange .daily"
).on("click", () => {
currentTimeRange = "daily";
update();
});
$(document).on("click", "#top #menu .text-button", (e) => {
if ($(e.currentTarget).hasClass("leaderboards")) {
show();

View file

@ -1613,6 +1613,17 @@ export async function finish(difficultyFailed = false): Promise<void> {
);
}
if (response.data.dailyLeaderboardRank) {
Notifications.add(
`New ${completedEvent.mode} ${completedEvent.mode2} rank: ` +
Misc.getPositionString(response.data.dailyLeaderboardRank),
1,
10,
"Daily Leaderboard",
"list-ol"
);
}
$("#retrySavingResultButton").addClass("hidden");
}

View file

@ -674,20 +674,20 @@
<div id="leaderboardsWrapper" class="popupWrapper hidden">
<div id="leaderboards">
<div class="leaderboardsTop">
<div class="mainTitle">Leaderboards</div>
<div class="mainTitle">All-Time English Leaderboards</div>
<div class="subTitle">Next update in: --:--</div>
<!-- <div class="buttons">
<div class="buttonGroup">
<div class="button active" board="time_15">time 15</div>
<div class="button" board="time_60">time 60</div>
</div>
</div> -->
<div class="buttons">
<div class="buttonGroup timeRange">
<div class="button allTime">all-time</div>
<div class="button daily">daily</div>
</div>
</div>
</div>
<div class="tables">
<div class="titleAndTable">
<div class="titleAndButtons">
<div class="title">
English Time 15
Time 15
<i
class="hidden leftTableLoader fas fa-fw fa-spin fa-circle-notch"
></i>
@ -731,7 +731,7 @@
<div class="titleAndTable">
<div class="titleAndButtons">
<div class="title">
English Time 60
Time 60
<i
class="hidden rightTableLoader fas fa-fw fa-spin fa-circle-notch"
></i>