From a12e5968da20650a4e3e86dc630429a92b1cf774 Mon Sep 17 00:00:00 2001 From: Sainan Date: Tue, 25 Mar 2025 03:25:58 -0700 Subject: [PATCH] feat: race leaderboards (#1314) Initial leaderboard system. Currently only tracking races, tho. Reviewed-on: https://onlyg.it/OpenWF/SpaceNinjaServer/pulls/1314 --- .../custom/deleteAccountController.ts | 2 + .../stats/leaderboardController.ts | 19 +++++ src/models/leaderboardModel.ts | 26 ++++++ src/routes/stats.ts | 7 +- src/services/leaderboardService.ts | 84 +++++++++++++++++++ src/services/statsService.ts | 25 ++++++ src/types/leaderboardTypes.ts | 17 ++++ 7 files changed, 177 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/custom/deleteAccountController.ts b/src/controllers/custom/deleteAccountController.ts index 63ade312..e5c466b4 100644 --- a/src/controllers/custom/deleteAccountController.ts +++ b/src/controllers/custom/deleteAccountController.ts @@ -8,6 +8,7 @@ import { PersonalRooms } from "@/src/models/personalRoomsModel"; import { Ship } from "@/src/models/shipModel"; import { Stats } from "@/src/models/statsModel"; import { GuildMember } from "@/src/models/guildModel"; +import { Leaderboard } from "@/src/models/leaderboardModel"; export const deleteAccountController: RequestHandler = async (req, res) => { const accountId = await getAccountIdForRequest(req); @@ -17,6 +18,7 @@ export const deleteAccountController: RequestHandler = async (req, res) => { GuildMember.deleteMany({ accountId: accountId }), Inbox.deleteMany({ ownerId: accountId }), Inventory.deleteOne({ accountOwnerId: accountId }), + Leaderboard.deleteMany({ ownerId: accountId }), Loadout.deleteOne({ loadoutOwnerId: accountId }), PersonalRooms.deleteOne({ personalRoomsOwnerId: accountId }), Ship.deleteMany({ ShipOwnerId: accountId }), diff --git a/src/controllers/stats/leaderboardController.ts b/src/controllers/stats/leaderboardController.ts new file mode 100644 index 00000000..b76e5e16 --- /dev/null +++ b/src/controllers/stats/leaderboardController.ts @@ -0,0 +1,19 @@ +import { getLeaderboard } from "@/src/services/leaderboardService"; +import { logger } from "@/src/utils/logger"; +import { RequestHandler } from "express"; + +export const leaderboardController: RequestHandler = async (req, res) => { + logger.debug(`data provided to ${req.path}: ${String(req.body)}`); + const payload = JSON.parse(String(req.body)) as ILeaderboardRequest; + res.json({ + results: await getLeaderboard(payload.field, payload.before, payload.after, payload.guildId, payload.pivotId) + }); +}; + +interface ILeaderboardRequest { + field: string; + before: number; + after: number; + guildId?: string; + pivotId?: string; +} diff --git a/src/models/leaderboardModel.ts b/src/models/leaderboardModel.ts new file mode 100644 index 00000000..5de2f608 --- /dev/null +++ b/src/models/leaderboardModel.ts @@ -0,0 +1,26 @@ +import { Document, model, Schema, Types } from "mongoose"; +import { ILeaderboardEntryDatabase } from "../types/leaderboardTypes"; + +const leaderboardEntrySchema = new Schema( + { + leaderboard: { type: String, required: true }, + ownerId: { type: Schema.Types.ObjectId, required: true }, + displayName: { type: String, required: true }, + score: { type: Number, required: true }, + guildId: Schema.Types.ObjectId, + expiry: { type: Date, required: true } + }, + { id: false } +); + +leaderboardEntrySchema.index({ leaderboard: 1 }); +leaderboardEntrySchema.index({ leaderboard: 1, ownerId: 1 }, { unique: true }); +leaderboardEntrySchema.index({ expiry: 1 }, { expireAfterSeconds: 0 }); // With this, MongoDB will automatically delete expired entries. + +export const Leaderboard = model("Leaderboard", leaderboardEntrySchema); + +// eslint-disable-next-line @typescript-eslint/ban-types +export type TLeaderboardEntryDocument = Document & { + _id: Types.ObjectId; + __v: number; +} & ILeaderboardEntryDatabase; diff --git a/src/routes/stats.ts b/src/routes/stats.ts index 59290675..11705259 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 00000000..afbe1623 --- /dev/null +++ b/src/services/leaderboardService.ts @@ -0,0 +1,84 @@ +import { Leaderboard, TLeaderboardEntryDocument } from "../models/leaderboardModel"; +import { ILeaderboardEntryClient } from "../types/leaderboardTypes"; + +export const submitLeaderboardScore = async ( + leaderboard: string, + ownerId: string, + displayName: string, + score: number, + guildId?: string +): 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, ownerId }, + { $max: { score }, $set: { displayName, guildId, expiry } }, + { upsert: true } + ); +}; + +export const getLeaderboard = async ( + leaderboard: string, + before: number, + after: number, + guildId?: string, + pivotId?: string +): Promise => { + const filter: { leaderboard: string; guildId?: string } = { leaderboard }; + if (guildId) { + filter.guildId = guildId; + } + + let entries: TLeaderboardEntryDocument[]; + let r: number; + if (pivotId) { + const pivotDoc = await Leaderboard.findOne({ ...filter, ownerId: pivotId }); + if (!pivotDoc) { + return []; + } + const beforeDocs = await Leaderboard.find({ + ...filter, + score: { $gt: pivotDoc.score } + }) + .sort({ score: 1 }) + .limit(before); + const afterDocs = await Leaderboard.find({ + ...filter, + score: { $lt: pivotDoc.score } + }) + .sort({ score: -1 }) + .limit(after); + entries = [...beforeDocs.reverse(), pivotDoc, ...afterDocs]; + r = + (await Leaderboard.countDocuments({ + ...filter, + score: { $gt: pivotDoc.score } + })) - beforeDocs.length; + } else { + entries = await Leaderboard.find(filter) + .sort({ score: -1 }) + .skip(before) + .limit(after - before); + r = before; + } + const res: ILeaderboardEntryClient[] = []; + for (const entry of entries) { + res.push({ + _id: entry.ownerId.toString(), + s: entry.score, + r: ++r, + n: entry.displayName + }); + } + return res; +}; diff --git a/src/services/statsService.ts b/src/services/statsService.ts index ef467723..a6ca826c 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,13 @@ export const updateStats = async (accountOwnerId: string, payload: IStatsUpdate) } else { playerStats.Races.set(race, { highScore }); } + + await submitLeaderboardScore( + "daily.accounts." + race, + accountOwnerId, + payload.displayName, + highScore + ); } break; @@ -308,9 +316,20 @@ export const updateStats = async (accountOwnerId: string, payload: IStatsUpdate) case "ZephyrScore": case "SentinelGameScore": case "CaliberChicksScore": + playerStats[category] ??= 0; + if (data > playerStats[category]) playerStats[category] = data as number; + break; + case "DojoObstacleScore": playerStats[category] ??= 0; if (data > playerStats[category]) playerStats[category] = data as number; + await submitLeaderboardScore( + "weekly.accounts." + category, + accountOwnerId, + payload.displayName, + data as number, + payload.guildId + ); break; case "OlliesCrashCourseScore": @@ -330,6 +349,12 @@ export const updateStats = async (accountOwnerId: string, payload: IStatsUpdate) ); } if (data > playerStats[category]) playerStats[category] = data as number; + await submitLeaderboardScore( + "weekly.accounts." + category, + accountOwnerId, + payload.displayName, + data as number + ); break; default: diff --git a/src/types/leaderboardTypes.ts b/src/types/leaderboardTypes.ts new file mode 100644 index 00000000..5173a3a3 --- /dev/null +++ b/src/types/leaderboardTypes.ts @@ -0,0 +1,17 @@ +import { Types } from "mongoose"; + +export interface ILeaderboardEntryDatabase { + leaderboard: string; + ownerId: Types.ObjectId; + displayName: string; + score: number; + guildId?: Types.ObjectId; + expiry: Date; +} + +export interface ILeaderboardEntryClient { + _id: string; // owner id + s: number; // score + r: number; // rank + n: string; // displayName +}