返回提交历史
Modified
src/controllers/custom/deleteAccountController.ts
+2
-0
Added
src/controllers/stats/leaderboardController.ts
+19
-0
Added
src/models/leaderboardModel.ts
+26
-0
Modified
src/routes/stats.ts
+4
-3
Added
src/services/leaderboardService.ts
+84
-0
Modified
src/services/statsService.ts
+25
-0
Added
src/types/leaderboardTypes.ts
+17
-0
XFEstudio/SpaceNinjaServer
feat: race leaderboards (#1314)
Initial leaderboard system. Currently only tracking races, tho. Reviewed-on: https://onlyg.it/OpenWF/SpaceNinjaServer/pulls/1314
a12e5968
代码差异
7 个文件
+177
-3
@@ -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 }),
@@ -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
}
@@ -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;
@@ -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 };
@@ -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
};
@@ -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:
@@ -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
}