From cc3880816aeaee6d2cb81444df03429592a950d8 Mon Sep 17 00:00:00 2001 From: Sainan Date: Mon, 24 Mar 2025 12:53:09 +0100 Subject: [PATCH] feat: daily race leaderboards --- .../stats/leaderboardController.ts | 15 ++++++ src/models/leaderboardModel.ts | 17 +++++++ src/routes/stats.ts | 7 +-- src/services/leaderboardService.ts | 49 +++++++++++++++++++ src/services/statsService.ts | 3 ++ src/types/leaderboardTypes.ts | 15 ++++++ 6 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 src/controllers/stats/leaderboardController.ts create mode 100644 src/models/leaderboardModel.ts create mode 100644 src/services/leaderboardService.ts create mode 100644 src/types/leaderboardTypes.ts diff --git a/src/controllers/stats/leaderboardController.ts b/src/controllers/stats/leaderboardController.ts new file mode 100644 index 000000000..bff1a7869 --- /dev/null +++ b/src/controllers/stats/leaderboardController.ts @@ -0,0 +1,15 @@ +import { getLeaderboard } from "@/src/services/leaderboardService"; +import { RequestHandler } from "express"; + +export const leaderboardController: RequestHandler = async (req, res) => { + const payload = JSON.parse(String(req.body)) as ILeaderboardRequest; + res.json({ + results: await getLeaderboard(payload.field, payload.before, payload.after) + }); +}; + +interface ILeaderboardRequest { + field: string; + before: number; + after: number; +} diff --git a/src/models/leaderboardModel.ts b/src/models/leaderboardModel.ts new file mode 100644 index 000000000..d6d031e98 --- /dev/null +++ b/src/models/leaderboardModel.ts @@ -0,0 +1,17 @@ +import { model, Schema } from "mongoose"; +import { ILeaderboardEntryDatabase } from "../types/leaderboardTypes"; + +const leaderboardEntrySchema = new Schema( + { + leaderboard: { type: String, required: true }, + displayName: { type: String, required: true }, + score: { type: Number, required: true }, + expiry: { type: Date, required: true } + }, + { id: false } +); + +leaderboardEntrySchema.index({ leaderboard: 1 }); +leaderboardEntrySchema.index({ expiry: 1 }, { expireAfterSeconds: 0 }); // With this, MongoDB will automatically delete expired entries. + +export const Leaderboard = model("Leaderboard", leaderboardEntrySchema); diff --git a/src/routes/stats.ts b/src/routes/stats.ts index 59290675f..11705259d 100644 --- a/src/routes/stats.ts +++ b/src/routes/stats.ts @@ -1,11 +1,12 @@ -import { viewController } from "../controllers/stats/viewController"; -import { uploadController } from "@/src/controllers/stats/uploadController"; - import express from "express"; +import { viewController } from "@/src/controllers/stats/viewController"; +import { uploadController } from "@/src/controllers/stats/uploadController"; +import { leaderboardController } from "@/src/controllers/stats/leaderboardController"; const statsRouter = express.Router(); statsRouter.get("/view.php", viewController); statsRouter.post("/upload.php", uploadController); +statsRouter.post("/leaderboardWeekly.php", leaderboardController); export { statsRouter }; diff --git a/src/services/leaderboardService.ts b/src/services/leaderboardService.ts new file mode 100644 index 000000000..0f8dbf96f --- /dev/null +++ b/src/services/leaderboardService.ts @@ -0,0 +1,49 @@ +import { toOid } from "../helpers/inventoryHelpers"; +import { Leaderboard } from "../models/leaderboardModel"; +import { ILeaderboardEntryClient } from "../types/leaderboardTypes"; + +export const submitLeaderboardScore = async ( + leaderboard: string, + displayName: string, + score: number +): Promise => { + const schedule = leaderboard.split(".")[0] as "daily" | "weekly"; + let expiry: Date; + if (schedule == "daily") { + expiry = new Date(Math.trunc(Date.now() / 86400000) * 86400000 + 86400000); + } else { + const EPOCH = 1734307200 * 1000; // Monday + const day = Math.trunc((Date.now() - EPOCH) / 86400000); + const week = Math.trunc(day / 7); + const weekStart = EPOCH + week * 604800000; + const weekEnd = weekStart + 604800000; + expiry = new Date(weekEnd); + } + await Leaderboard.findOneAndUpdate( + { leaderboard, displayName }, + { $max: { score }, $set: { expiry } }, + { upsert: true } + ); +}; + +export const getLeaderboard = async ( + leaderboard: string, + before: number, + after: number +): Promise => { + const entries = await Leaderboard.find({ leaderboard }) + .sort({ score: -1 }) + .skip(before) + .limit(after - before); + const res: ILeaderboardEntryClient[] = []; + let r = before; + for (const entry of entries) { + res.push({ + _id: toOid(entry._id), + s: entry.score, + r: ++r, + n: entry.displayName + }); + } + return res; +}; diff --git a/src/services/statsService.ts b/src/services/statsService.ts index ef467723c..efc1c8deb 100644 --- a/src/services/statsService.ts +++ b/src/services/statsService.ts @@ -11,6 +11,7 @@ import { } from "@/src/types/statTypes"; import { logger } from "@/src/utils/logger"; import { addEmailItem, getInventory } from "@/src/services/inventoryService"; +import { submitLeaderboardScore } from "./leaderboardService"; export const createStats = async (accountId: string): Promise => { const stats = new Stats({ accountOwnerId: accountId }); @@ -301,6 +302,8 @@ export const updateStats = async (accountOwnerId: string, payload: IStatsUpdate) } else { playerStats.Races.set(race, { highScore }); } + + await submitLeaderboardScore("daily.accounts." + race, payload.displayName, highScore); } break; diff --git a/src/types/leaderboardTypes.ts b/src/types/leaderboardTypes.ts new file mode 100644 index 000000000..b1fb253c4 --- /dev/null +++ b/src/types/leaderboardTypes.ts @@ -0,0 +1,15 @@ +import { IOid } from "./commonTypes"; + +export interface ILeaderboardEntryDatabase { + leaderboard: string; + displayName: string; + score: number; + expiry: Date; +} + +export interface ILeaderboardEntryClient { + _id: IOid; + s: number; // score + r: number; // rank + n: string; // displayName +}