XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFESpaceNinjaServer

A simple server for a small space ninja game

公开
关注 0 Fork 0 Star 0
返回提交历史

XFEstudio/XFESpaceNinjaServer

feat: race leaderboards (#1314)

Initial leaderboard system. Currently only tracking races, tho. Reviewed-on: https://onlyg.it/OpenWF/SpaceNinjaServer/pulls/1314

a12e5968
Sainan <sainan@calamity.inc>
提交于

代码差异

7 个文件 +177 -3
Modified src/controllers/custom/deleteAccountController.ts +2 -0
@@ -8,6 +8,7 @@ import { PersonalRooms } from "@/src/models/personalRoomsModel";
8 8 import { Ship } from "@/src/models/shipModel";
9 9 import { Stats } from "@/src/models/statsModel";
10 10 import { GuildMember } from "@/src/models/guildModel";
11 import { Leaderboard } from "@/src/models/leaderboardModel";
11 12
12 13 export const deleteAccountController: RequestHandler = async (req, res) => {
13 14 const accountId = await getAccountIdForRequest(req);
@@ -17,6 +18,7 @@ export const deleteAccountController: RequestHandler = async (req, res) => {
17 18 GuildMember.deleteMany({ accountId: accountId }),
18 19 Inbox.deleteMany({ ownerId: accountId }),
19 20 Inventory.deleteOne({ accountOwnerId: accountId }),
21 Leaderboard.deleteMany({ ownerId: accountId }),
20 22 Loadout.deleteOne({ loadoutOwnerId: accountId }),
21 23 PersonalRooms.deleteOne({ personalRoomsOwnerId: accountId }),
22 24 Ship.deleteMany({ ShipOwnerId: accountId }),
Added src/controllers/stats/leaderboardController.ts +19 -0
@@ -0,0 +1,19 @@
1 import { getLeaderboard } from "@/src/services/leaderboardService";
2 import { logger } from "@/src/utils/logger";
3 import { RequestHandler } from "express";
4
5 export const leaderboardController: RequestHandler = async (req, res) => {
6 logger.debug(`data provided to ${req.path}: ${String(req.body)}`);
7 const payload = JSON.parse(String(req.body)) as ILeaderboardRequest;
8 res.json({
9 results: await getLeaderboard(payload.field, payload.before, payload.after, payload.guildId, payload.pivotId)
10 });
11 };
12
13 interface ILeaderboardRequest {
14 field: string;
15 before: number;
16 after: number;
17 guildId?: string;
18 pivotId?: string;
19 }
Added src/models/leaderboardModel.ts +26 -0
@@ -0,0 +1,26 @@
1 import { Document, model, Schema, Types } from "mongoose";
2 import { ILeaderboardEntryDatabase } from "../types/leaderboardTypes";
3
4 const leaderboardEntrySchema = new Schema<ILeaderboardEntryDatabase>(
5 {
6 leaderboard: { type: String, required: true },
7 ownerId: { type: Schema.Types.ObjectId, required: true },
8 displayName: { type: String, required: true },
9 score: { type: Number, required: true },
10 guildId: Schema.Types.ObjectId,
11 expiry: { type: Date, required: true }
12 },
13 { id: false }
14 );
15
16 leaderboardEntrySchema.index({ leaderboard: 1 });
17 leaderboardEntrySchema.index({ leaderboard: 1, ownerId: 1 }, { unique: true });
18 leaderboardEntrySchema.index({ expiry: 1 }, { expireAfterSeconds: 0 }); // With this, MongoDB will automatically delete expired entries.
19
20 export const Leaderboard = model<ILeaderboardEntryDatabase>("Leaderboard", leaderboardEntrySchema);
21
22 // eslint-disable-next-line @typescript-eslint/ban-types
23 export type TLeaderboardEntryDocument = Document<unknown, {}, ILeaderboardEntryDatabase> & {
24 _id: Types.ObjectId;
25 __v: number;
26 } & ILeaderboardEntryDatabase;
Modified src/routes/stats.ts +4 -3
@@ -1,11 +1,12 @@
1 import { viewController } from "../controllers/stats/viewController";
2 import { uploadController } from "@/src/controllers/stats/uploadController";
3
4 1 import express from "express";
2 import { viewController } from "@/src/controllers/stats/viewController";
3 import { uploadController } from "@/src/controllers/stats/uploadController";
4 import { leaderboardController } from "@/src/controllers/stats/leaderboardController";
5 5
6 6 const statsRouter = express.Router();
7 7
8 8 statsRouter.get("/view.php", viewController);
9 9 statsRouter.post("/upload.php", uploadController);
10 statsRouter.post("/leaderboardWeekly.php", leaderboardController);
10 11
11 12 export { statsRouter };
Added src/services/leaderboardService.ts +84 -0
@@ -0,0 +1,84 @@
1 import { Leaderboard, TLeaderboardEntryDocument } from "../models/leaderboardModel";
2 import { ILeaderboardEntryClient } from "../types/leaderboardTypes";
3
4 export const submitLeaderboardScore = async (
5 leaderboard: string,
6 ownerId: string,
7 displayName: string,
8 score: number,
9 guildId?: string
10 ): Promise<void> => {
11 const schedule = leaderboard.split(".")[0] as "daily" | "weekly";
12 let expiry: Date;
13 if (schedule == "daily") {
14 expiry = new Date(Math.trunc(Date.now() / 86400000) * 86400000 + 86400000);
15 } else {
16 const EPOCH = 1734307200 * 1000; // Monday
17 const day = Math.trunc((Date.now() - EPOCH) / 86400000);
18 const week = Math.trunc(day / 7);
19 const weekStart = EPOCH + week * 604800000;
20 const weekEnd = weekStart + 604800000;
21 expiry = new Date(weekEnd);
22 }
23 await Leaderboard.findOneAndUpdate(
24 { leaderboard, ownerId },
25 { $max: { score }, $set: { displayName, guildId, expiry } },
26 { upsert: true }
27 );
28 };
29
30 export const getLeaderboard = async (
31 leaderboard: string,
32 before: number,
33 after: number,
34 guildId?: string,
35 pivotId?: string
36 ): Promise<ILeaderboardEntryClient[]> => {
37 const filter: { leaderboard: string; guildId?: string } = { leaderboard };
38 if (guildId) {
39 filter.guildId = guildId;
40 }
41
42 let entries: TLeaderboardEntryDocument[];
43 let r: number;
44 if (pivotId) {
45 const pivotDoc = await Leaderboard.findOne({ ...filter, ownerId: pivotId });
46 if (!pivotDoc) {
47 return [];
48 }
49 const beforeDocs = await Leaderboard.find({
50 ...filter,
51 score: { $gt: pivotDoc.score }
52 })
53 .sort({ score: 1 })
54 .limit(before);
55 const afterDocs = await Leaderboard.find({
56 ...filter,
57 score: { $lt: pivotDoc.score }
58 })
59 .sort({ score: -1 })
60 .limit(after);
61 entries = [...beforeDocs.reverse(), pivotDoc, ...afterDocs];
62 r =
63 (await Leaderboard.countDocuments({
64 ...filter,
65 score: { $gt: pivotDoc.score }
66 })) - beforeDocs.length;
67 } else {
68 entries = await Leaderboard.find(filter)
69 .sort({ score: -1 })
70 .skip(before)
71 .limit(after - before);
72 r = before;
73 }
74 const res: ILeaderboardEntryClient[] = [];
75 for (const entry of entries) {
76 res.push({
77 _id: entry.ownerId.toString(),
78 s: entry.score,
79 r: ++r,
80 n: entry.displayName
81 });
82 }
83 return res;
84 };
Modified src/services/statsService.ts +25 -0
@@ -11,6 +11,7 @@ import {
11 11 } from "@/src/types/statTypes";
12 12 import { logger } from "@/src/utils/logger";
13 13 import { addEmailItem, getInventory } from "@/src/services/inventoryService";
14 import { submitLeaderboardScore } from "./leaderboardService";
14 15
15 16 export const createStats = async (accountId: string): Promise<TStatsDatabaseDocument> => {
16 17 const stats = new Stats({ accountOwnerId: accountId });
@@ -301,6 +302,13 @@ export const updateStats = async (accountOwnerId: string, payload: IStatsUpdate)
301 302 } else {
302 303 playerStats.Races.set(race, { highScore });
303 304 }
305
306 await submitLeaderboardScore(
307 "daily.accounts." + race,
308 accountOwnerId,
309 payload.displayName,
310 highScore
311 );
304 312 }
305 313
306 314 break;
@@ -308,9 +316,20 @@ export const updateStats = async (accountOwnerId: string, payload: IStatsUpdate)
308 316 case "ZephyrScore":
309 317 case "SentinelGameScore":
310 318 case "CaliberChicksScore":
319 playerStats[category] ??= 0;
320 if (data > playerStats[category]) playerStats[category] = data as number;
321 break;
322
311 323 case "DojoObstacleScore":
312 324 playerStats[category] ??= 0;
313 325 if (data > playerStats[category]) playerStats[category] = data as number;
326 await submitLeaderboardScore(
327 "weekly.accounts." + category,
328 accountOwnerId,
329 payload.displayName,
330 data as number,
331 payload.guildId
332 );
314 333 break;
315 334
316 335 case "OlliesCrashCourseScore":
@@ -330,6 +349,12 @@ export const updateStats = async (accountOwnerId: string, payload: IStatsUpdate)
330 349 );
331 350 }
332 351 if (data > playerStats[category]) playerStats[category] = data as number;
352 await submitLeaderboardScore(
353 "weekly.accounts." + category,
354 accountOwnerId,
355 payload.displayName,
356 data as number
357 );
333 358 break;
334 359
335 360 default:
Added src/types/leaderboardTypes.ts +17 -0
@@ -0,0 +1,17 @@
1 import { Types } from "mongoose";
2
3 export interface ILeaderboardEntryDatabase {
4 leaderboard: string;
5 ownerId: Types.ObjectId;
6 displayName: string;
7 score: number;
8 guildId?: Types.ObjectId;
9 expiry: Date;
10 }
11
12 export interface ILeaderboardEntryClient {
13 _id: string; // owner id
14 s: number; // score
15 r: number; // rank
16 n: string; // displayName
17 }