forked from OpenWF/SpaceNinjaServer
		
	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