feat: race leaderboards (#1314)
Initial leaderboard system. Currently only tracking races, tho. Reviewed-on: OpenWF/SpaceNinjaServer#1314
This commit is contained in:
parent
2ec2b0278a
commit
a12e5968da
@ -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 }),
|
||||
|
19
src/controllers/stats/leaderboardController.ts
Normal file
19
src/controllers/stats/leaderboardController.ts
Normal file
@ -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;
|
||||
}
|
26
src/models/leaderboardModel.ts
Normal file
26
src/models/leaderboardModel.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { Document, model, Schema, Types } from "mongoose";
|
||||
import { ILeaderboardEntryDatabase } from "../types/leaderboardTypes";
|
||||
|
||||
const leaderboardEntrySchema = new Schema<ILeaderboardEntryDatabase>(
|
||||
{
|
||||
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<ILeaderboardEntryDatabase>("Leaderboard", leaderboardEntrySchema);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
export type TLeaderboardEntryDocument = Document<unknown, {}, ILeaderboardEntryDatabase> & {
|
||||
_id: Types.ObjectId;
|
||||
__v: number;
|
||||
} & ILeaderboardEntryDatabase;
|
@ -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 };
|
||||
|
84
src/services/leaderboardService.ts
Normal file
84
src/services/leaderboardService.ts
Normal file
@ -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<void> => {
|
||||
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<ILeaderboardEntryClient[]> => {
|
||||
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;
|
||||
};
|
@ -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<TStatsDatabaseDocument> => {
|
||||
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:
|
||||
|
17
src/types/leaderboardTypes.ts
Normal file
17
src/types/leaderboardTypes.ts
Normal file
@ -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
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user