diff --git a/apps/api/src/dtos/guild-leaderboard.dto.ts b/apps/api/src/dtos/guild-leaderboard.dto.ts index 483977c3a..eb297cc36 100644 --- a/apps/api/src/dtos/guild-leaderboard.dto.ts +++ b/apps/api/src/dtos/guild-leaderboard.dto.ts @@ -11,6 +11,7 @@ import { Guild, getLeaderboardFields } from "@statsify/schemas"; import { IsEnum, IsInt, + IsNumber, IsOptional, IsString, MaxLength, @@ -39,6 +40,12 @@ export class GuildLeaderboardDto { @ApiProperty({ minimum: 1, type: () => Number, required: false }) public position?: number; + @Transform((params) => +params.value) + @IsOptional() + @IsNumber() + @ApiProperty({ type: () => Number, required: false }) + public value?: number; + @IsOptional() @IsString() @MinLength(3) diff --git a/apps/api/src/dtos/player-leaderboard.dto.ts b/apps/api/src/dtos/player-leaderboard.dto.ts index 0e39b70ed..f874ab3d0 100644 --- a/apps/api/src/dtos/player-leaderboard.dto.ts +++ b/apps/api/src/dtos/player-leaderboard.dto.ts @@ -7,7 +7,7 @@ */ import { ApiProperty, PartialType } from "@nestjs/swagger"; -import { IsEnum, IsInt, IsOptional, Min } from "class-validator"; +import { IsEnum, IsInt, IsNumber, IsOptional, Min } from "class-validator"; import { Player, getLeaderboardFields } from "@statsify/schemas"; import { PlayerDto } from "./player.dto.js"; import { Transform } from "class-transformer"; @@ -31,4 +31,10 @@ export class PlayerLeaderboardDto extends PartialType(PlayerDto) { @Min(1) @ApiProperty({ minimum: 1, type: () => Number, required: false }) public position?: number; + + @Transform((params) => +params.value) + @IsOptional() + @IsNumber() + @ApiProperty({ type: () => Number, required: false }) + public value?: number; } diff --git a/apps/api/src/guild/leaderboards/guild-leaderboard.controller.ts b/apps/api/src/guild/leaderboards/guild-leaderboard.controller.ts index 70eeb3474..56e58ed60 100644 --- a/apps/api/src/guild/leaderboards/guild-leaderboard.controller.ts +++ b/apps/api/src/guild/leaderboards/guild-leaderboard.controller.ts @@ -29,7 +29,7 @@ export class GuildLeaderboardController { @ApiBadRequestResponse({ type: ErrorResponse }) @Auth({ weight: 10 }) public async getGuildLeaderboard( - @Body() { field, page, guild, position }: GuildLeaderboardDto + @Body() { field, page, guild, position, value }: GuildLeaderboardDto ) { let input: number | string; let type: LeaderboardQuery; @@ -40,6 +40,9 @@ export class GuildLeaderboardController { } else if (position) { input = position; type = LeaderboardQuery.POSITION; + } else if (value !== undefined) { + input = value; + type = LeaderboardQuery.VALUE; } else { input = page; type = LeaderboardQuery.PAGE; diff --git a/apps/api/src/leaderboards/leaderboard.service.ts b/apps/api/src/leaderboards/leaderboard.service.ts index 58a15af98..d137248d5 100644 --- a/apps/api/src/leaderboards/leaderboard.service.ts +++ b/apps/api/src/leaderboards/leaderboard.service.ts @@ -10,7 +10,7 @@ import * as Sentry from "@sentry/node"; import { Constructor, Flatten } from "@statsify/util"; import { DateTime } from "luxon"; import { InjectRedis } from "#redis"; -import { Injectable, InternalServerErrorException } from "@nestjs/common"; +import { Injectable, InternalServerErrorException, NotFoundException } from "@nestjs/common"; import { type LeaderboardEnabledMetadata, getLeaderboardField, getLeaderboardFields } from "@statsify/schemas"; import { LeaderboardQuery } from "@statsify/api-client"; import { Redis } from "ioredis"; @@ -106,14 +106,25 @@ export abstract class LeaderboardService { case LeaderboardQuery.INPUT: { const ranking = await this.searchLeaderboardInput(input as string, field); highlight = ranking - 1; - top = highlight - (highlight % 10); + top = highlight - (highlight % PAGE_SIZE); bottom = top + PAGE_SIZE; break; } case LeaderboardQuery.POSITION: { const position = (input as number) - 1; highlight = position; - top = position - (position % 10); + top = position - (position % PAGE_SIZE); + bottom = top + PAGE_SIZE; + break; + } + case LeaderboardQuery.VALUE: { + highlight = await this.searchLeaderboardValue( + constructor, + field, + input as number, + sort + ); + top = highlight - (highlight % PAGE_SIZE); bottom = top + PAGE_SIZE; break; } @@ -302,6 +313,27 @@ export abstract class LeaderboardService { return response; } + private async searchLeaderboardValue( + constructor: Constructor, + field: string, + value: number, + sort = "DESC" + ) { + const key = `${constructor.name.toLowerCase()}.${field}`; + + const [position, size] = await Promise.all([ + sort === "ASC" ? + this.redis.zcount(key, "-inf", `(${value}`) : + this.redis.zcount(key, `(${value}`, "+inf"), + this.redis.zcard(key), + ]); + + // Leaderboards are trimmed to their limit, so a value past the end has no known position + if (position >= size) throw new NotFoundException("value"); + + return position; + } + private getLeaderboardExpiryTime(leaderboard: LeaderboardEnabledMetadata): number { if (!leaderboard.resetEvery) throw new Error("To get a leaderboard expiry time, `resetEvery` must be specified"); diff --git a/apps/api/src/player/leaderboards/player-leaderboard.controller.ts b/apps/api/src/player/leaderboards/player-leaderboard.controller.ts index 567d5f4cf..c8b142e6a 100644 --- a/apps/api/src/player/leaderboards/player-leaderboard.controller.ts +++ b/apps/api/src/player/leaderboards/player-leaderboard.controller.ts @@ -37,7 +37,7 @@ export class PlayerLeaderboardsController { @ApiBadRequestResponse({ type: ErrorResponse }) @Auth({ weight: 3 }) public getPlayerLeaderboard( - @Body() { field, page, player, position }: PlayerLeaderboardDto + @Body() { field, page, player, position, value }: PlayerLeaderboardDto ) { let input: number | string; let type: LeaderboardQuery; @@ -48,6 +48,9 @@ export class PlayerLeaderboardsController { } else if (position) { input = position; type = LeaderboardQuery.POSITION; + } else if (value !== undefined) { + input = value; + type = LeaderboardQuery.VALUE; } else { input = page; type = LeaderboardQuery.PAGE; diff --git a/apps/discord-bot/src/commands/leaderboards/base.leaderboard-command.tsx b/apps/discord-bot/src/commands/leaderboards/base.leaderboard-command.tsx index b9305dfab..ffca17d82 100644 --- a/apps/discord-bot/src/commands/leaderboards/base.leaderboard-command.tsx +++ b/apps/discord-bot/src/commands/leaderboards/base.leaderboard-command.tsx @@ -27,7 +27,7 @@ import { LeaderboardQuery, PostLeaderboardResponse, } from "@statsify/api-client"; -import { User } from "@statsify/schemas"; +import { Guild, Player, User, parseLeaderboardValue } from "@statsify/schemas"; import { getLogo } from "@statsify/assets"; import { getTheme } from "#themes"; import { render } from "@statsify/rendering"; @@ -96,6 +96,18 @@ export class BaseLeaderboardCommand { .emoji(t("emojis:search.position")) .style(ButtonStyle.Primary); + const searchValue = new ButtonBuilder() + .emoji(t("emojis:search.value")) + .style(ButtonStyle.Primary); + + const row = new ActionRowBuilder([ + up, + down, + searchDocument, + searchPosition, + searchValue, + ]); + let currentPage = 0; const changePage = @@ -118,7 +130,6 @@ export class BaseLeaderboardCommand { if (interaction.getUserId() === userId && !message.ephemeral) { up.disable(page === 0); currentPage = page || currentPage; - const row = new ActionRowBuilder([up, down, searchDocument, searchPosition]); context.reply({ ...message, @@ -187,6 +198,19 @@ export class BaseLeaderboardCommand { ) ); + const valueModal = new ModalBuilder() + .title((t) => t("leaderboard.modal.title")) + .component( + new ActionRowBuilder().component( + new TextInputBuilder() + .label((t) => t("leaderboard.valueInput.label")) + .placeholder((t) => t("leaderboard.valueInput.placeholder")) + .minLength(1) + .maxLength(16) + .required(true) + ) + ); + listener.addHook(searchDocument.getCustomId(), () => ({ type: InteractionResponseType.Modal, data: documentModal.build(t), @@ -197,6 +221,11 @@ export class BaseLeaderboardCommand { data: positionModal.build(t), })); + listener.addHook(searchValue.getCustomId(), () => ({ + type: InteractionResponseType.Modal, + data: valueModal.build(t), + })); + listener.addHook(documentModal.getCustomId(), async (interaction) => { const data = interaction.getData(); const documentInput = data.components[0].components[0].value; @@ -227,15 +256,41 @@ export class BaseLeaderboardCommand { ); }); - const row = new ActionRowBuilder([up, down, searchDocument, searchPosition]); + listener.addHook(valueModal.getCustomId(), async (interaction) => { + const data = interaction.getData(); + const valueInput = data.components[0].components[0].value; + + const value = parseLeaderboardValue( + type === "player" ? Player : Guild, + field, + valueInput + ); + + if (user?.locale) interaction.setLocale(user.locale); + + if (Number.isNaN(value)) { + const error = new ErrorMessage("errors.leaderboardInvalidValue"); + + return interaction.sendFollowup({ + ...error, + ephemeral: true, + }); + } + + changePage(() => ({ input: value, type: LeaderboardQuery.VALUE }))( + interaction + ); + }); setTimeout(() => { listener.removeHook(up.getCustomId()); listener.removeHook(down.getCustomId()); listener.removeHook(searchDocument.getCustomId()); listener.removeHook(searchPosition.getCustomId()); + listener.removeHook(searchValue.getCustomId()); listener.removeHook(documentModal.getCustomId()); listener.removeHook(positionModal.getCustomId()); + listener.removeHook(valueModal.getCustomId()); context.reply({ embeds: [], components: [] }); cache.clear(); @@ -289,7 +344,11 @@ export class BaseLeaderboardCommand { if (!leaderboard) { const message = { - ...new ErrorMessage(`errors.${type}LeaderboardNotFound`), + ...new ErrorMessage( + params.type === LeaderboardQuery.VALUE ? + "errors.leaderboardValueNotFound" : + `errors.${type}LeaderboardNotFound` + ), ephemeral: true, }; diff --git a/locales/en-US/default.json b/locales/en-US/default.json index 642eaad06..e31eefd1d 100644 --- a/locales/en-US/default.json +++ b/locales/en-US/default.json @@ -307,7 +307,7 @@ "title": "Sessions" }, "leaderboards": { - "description": "Statsify offers thousands of leaderboards through the `/leaderboard` command. Run `/leaderboard`, then pick the gamemode, and pick a leaderboard to view. You can hit the search button to search for a specific player or position. You can also hit the up and down buttons to view more pages.", + "description": "Statsify offers thousands of leaderboards through the `/leaderboard` command. Run `/leaderboard`, then pick the gamemode, and pick a leaderboard to view. You can hit the search buttons to search for a specific player, position or value. You can also hit the up and down buttons to view more pages.", "title": "Leaderboards" }, "minecraft": { @@ -415,10 +415,18 @@ "description": "The leaderboard position you entered is invalid. Enter a number greater than 1.", "title": "Invalid Position" }, + "leaderboardInvalidValue": { + "description": "The leaderboard value you entered is invalid. Enter a number like `10k`, a level like `500` or a time like `1:23.45` or `5d 3h`.", + "title": "Invalid Value" + }, "leaderboardNotFound": { "description": "We can't find the `leaderboard` you are looking for!", "title": "Leaderboard Not Found" }, + "leaderboardValueNotFound": { + "description": "The leaderboard doesn't go down as far as the value you entered!", + "title": "No Leaderboard Data At This Value" + }, "missingMentionVerification": { "description": "<@{{tag}}> is not **verified**. To mention them, they must run `/verify` to verify their account.", "title": "Missing Verification" @@ -511,6 +519,10 @@ "positionInput": { "label": "Search by a Position", "placeholder": "Enter a position, eg: 5000" + }, + "valueInput": { + "label": "Search by a Value", + "placeholder": "Enter a value, eg: 10k, 500, 1:23.45 or 5d 3h" } }, "minecraft": { diff --git a/locales/en-US/emojis.json b/locales/en-US/emojis.json index 66a9047b0..d865ecbcc 100644 --- a/locales/en-US/emojis.json +++ b/locales/en-US/emojis.json @@ -4,7 +4,8 @@ "search": { "player": "<:searchplayer:995761551913005166>", "guild": "<:searchguild:996434315938381844>", - "position": "<:searchposition:995761551048966154>" + "position": "<:searchposition:995761551048966154>", + "value": "<:searchvalue:1509622552828317696>" }, "check": "<:check:995761550310772777>", "cross": "<:cross:995761549316726884>", diff --git a/packages/api-client/src/constants.ts b/packages/api-client/src/constants.ts index 52310234b..a32dc88da 100644 --- a/packages/api-client/src/constants.ts +++ b/packages/api-client/src/constants.ts @@ -37,5 +37,6 @@ export enum CacheLevel { export enum LeaderboardQuery { PAGE = "page", INPUT = "input", - POSITION = "position" + POSITION = "position", + VALUE = "value" } diff --git a/packages/schemas/src/guild/index.ts b/packages/schemas/src/guild/index.ts index 33c427164..e5d07a8da 100644 --- a/packages/schemas/src/guild/index.ts +++ b/packages/schemas/src/guild/index.ts @@ -39,7 +39,13 @@ export class Guild { public createdAt: number; @Field({ - leaderboard: { fieldName: "Level", hidden: true, additionalFields: ["level"], limit }, + leaderboard: { + fieldName: "Level", + hidden: true, + additionalFields: ["level"], + limit, + searchBy: (exp: number) => getLevel(exp).level, + }, }) public exp: number; diff --git a/packages/schemas/src/metadata/field/get-leaderboard-metadata.ts b/packages/schemas/src/metadata/field/get-leaderboard-metadata.ts index 4f10a81b1..258b09cc6 100644 --- a/packages/schemas/src/metadata/field/get-leaderboard-metadata.ts +++ b/packages/schemas/src/metadata/field/get-leaderboard-metadata.ts @@ -105,6 +105,8 @@ export const getLeaderboardMetadata = ( limit: leaderboardOptions?.limit ?? getDefaultLeaderboardLimit(propertyKey), resetEvery: leaderboardOptions?.resetEvery, + parser: leaderboardOptions?.parser, + searchBy: leaderboardOptions?.searchBy, }; } diff --git a/packages/schemas/src/metadata/metadata.interface.ts b/packages/schemas/src/metadata/metadata.interface.ts index cff6337f3..347641300 100644 --- a/packages/schemas/src/metadata/metadata.interface.ts +++ b/packages/schemas/src/metadata/metadata.interface.ts @@ -109,6 +109,16 @@ export interface LeaderboardEnabledMetadata extends BaseLeaderboardMetadata { * How many members to show in the leaderboard. */ limit: number; + + /** + * Reads a leaderboard search back into a score, the reverse of the formatter. + */ + parser?: (input: string) => number; + + /** + * Turns a score into what users search by, like exp into a level. It must never go down as the score goes up. + */ + searchBy?: (score: number) => number; } export type LeaderboardMetadata = diff --git a/packages/schemas/src/player/gamemodes/arcade/mode.ts b/packages/schemas/src/player/gamemodes/arcade/mode.ts index e2dd5a1a2..68f951dd7 100644 --- a/packages/schemas/src/player/gamemodes/arcade/mode.ts +++ b/packages/schemas/src/player/gamemodes/arcade/mode.ts @@ -6,7 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { type APIData, formatRaceTime, formatTime } from "@statsify/util"; +import { type APIData, formatRaceTime, formatTime, parseTime } from "@statsify/util"; import { EasterSimulator, GrinchSimulator, @@ -415,7 +415,7 @@ export class Disasters { @Field() public wlr: number; - @Field({ leaderboard: { formatter: formatTime } }) + @Field({ leaderboard: { formatter: formatTime, parser: parseTime } }) public playtime: number; @Field({ leaderboard: { name: "" } }) @@ -460,7 +460,7 @@ export class DropperMap { @Field() public completions: number; - @Field({ leaderboard: { formatter: formatRaceTime, sort: "ASC" } }) + @Field({ leaderboard: { formatter: formatRaceTime, parser: parseTime, sort: "ASC" } }) public bestTime: number; public constructor(map: APIData = {}) { @@ -733,7 +733,7 @@ export class Dropper { @Field() public flawlessGames: number; - @Field({ leaderboard: { formatter: formatTime, sort: "ASC" } }) + @Field({ leaderboard: { formatter: formatTime, parser: parseTime, sort: "ASC" } }) public bestTime: number; @Field({ leaderboard: { name: "Maps:" } }) @@ -1147,32 +1147,32 @@ export class PartyGames { public workshopWins: number; // The goal of anvil spleef is to live the longest not the shortest so we sort in descending order - @Field({ leaderboard: { formatter: formatRaceTime } }) + @Field({ leaderboard: { formatter: formatRaceTime, parser: parseTime } }) public anvilSpleefBestTime: number; - @Field({ leaderboard: { formatter: formatRaceTime, sort: "ASC" } }) + @Field({ leaderboard: { formatter: formatRaceTime, parser: parseTime, sort: "ASC" } }) public labEscapeBestTime: number; - @Field({ leaderboard: { formatter: formatRaceTime, sort: "ASC" } }) + @Field({ leaderboard: { formatter: formatRaceTime, parser: parseTime, sort: "ASC" } }) public jigsawRushBestTime: number; - @Field({ leaderboard: { formatter: formatRaceTime, sort: "ASC" } }) + @Field({ leaderboard: { formatter: formatRaceTime, parser: parseTime, sort: "ASC" } }) public theFloorIsLavaBestTime: number; - @Field({ leaderboard: { formatter: formatRaceTime, sort: "ASC" } }) + @Field({ leaderboard: { formatter: formatRaceTime, parser: parseTime, sort: "ASC" } }) public chickenRingsBestTime: number; - @Field({ leaderboard: { formatter: formatRaceTime, sort: "ASC" } }) + @Field({ leaderboard: { formatter: formatRaceTime, parser: parseTime, sort: "ASC" } }) public jungleJumpBestTime: number; // The goal of bombardment is to live the longest not the shortest so we sort in descending order - @Field({ leaderboard: { formatter: formatRaceTime } }) + @Field({ leaderboard: { formatter: formatRaceTime, parser: parseTime } }) public bombardmentBestTime: number; - @Field({ leaderboard: { formatter: formatRaceTime, sort: "ASC" } }) + @Field({ leaderboard: { formatter: formatRaceTime, parser: parseTime, sort: "ASC" } }) public minecartRacingBestTime: number; - @Field({ leaderboard: { formatter: formatRaceTime, sort: "ASC" } }) + @Field({ leaderboard: { formatter: formatRaceTime, parser: parseTime, sort: "ASC" } }) public spiderMazeBestTime: number; @Field() @@ -1371,6 +1371,7 @@ export class ZombiesMapDifficulty { leaderboard: { sort: "ASC", formatter: formatTime, + parser: parseTime, additionalFields: ["this.wins"], }, historical: { enabled: false }, diff --git a/packages/schemas/src/player/gamemodes/bedwars/index.ts b/packages/schemas/src/player/gamemodes/bedwars/index.ts index f3a824a69..d4fcf2e37 100644 --- a/packages/schemas/src/player/gamemodes/bedwars/index.ts +++ b/packages/schemas/src/player/gamemodes/bedwars/index.ts @@ -86,6 +86,7 @@ export class BedWars { hidden: true, formatter: (exp: number) => getIntendedFormattedLevel(Math.floor(getLevel(exp))), + searchBy: getLevel, additionalFields: [ "this.overall.wins", "this.overall.finalKills", diff --git a/packages/schemas/src/player/gamemodes/blitzsg/kit.ts b/packages/schemas/src/player/gamemodes/blitzsg/kit.ts index f6eb86f38..68d9d36d7 100644 --- a/packages/schemas/src/player/gamemodes/blitzsg/kit.ts +++ b/packages/schemas/src/player/gamemodes/blitzsg/kit.ts @@ -6,7 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { type APIData, findScoreIndex, formatTime } from "@statsify/util"; +import { type APIData, findScoreIndex, formatTime, parseTime } from "@statsify/util"; import { Field } from "#metadata"; import { add, ratio, sub } from "@statsify/math"; @@ -45,7 +45,7 @@ export class BlitzSGKit { @Field({ store: { required: false }, - leaderboard: { formatter: formatTime }, + leaderboard: { formatter: formatTime, parser: parseTime }, historical: { enabled: false }, }) public playtime: number; diff --git a/packages/schemas/src/player/gamemodes/duels/mode.ts b/packages/schemas/src/player/gamemodes/duels/mode.ts index 827f27931..433de6766 100644 --- a/packages/schemas/src/player/gamemodes/duels/mode.ts +++ b/packages/schemas/src/player/gamemodes/duels/mode.ts @@ -6,7 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { type APIData, formatTime } from "@statsify/util"; +import { type APIData, formatTime, parseTime } from "@statsify/util"; import { Field } from "#metadata"; import { Progression } from "#progression"; import { TitleRequirement, getTitleAndProgression } from "./util.js"; @@ -648,7 +648,7 @@ export class ParkourDuels extends SingleDuelsGameMode { @Field() public checkpoints: number; - @Field({ leaderboard: { formatter: formatTime, sort: "ASC" } }) + @Field({ leaderboard: { formatter: formatTime, parser: parseTime, sort: "ASC" } }) public bestTime: number; public constructor(data: APIData) { diff --git a/packages/schemas/src/player/gamemodes/general/index.ts b/packages/schemas/src/player/gamemodes/general/index.ts index f3e6dd85e..c710222a3 100644 --- a/packages/schemas/src/player/gamemodes/general/index.ts +++ b/packages/schemas/src/player/gamemodes/general/index.ts @@ -32,6 +32,7 @@ export class General { hidden: true, limit: Number.POSITIVE_INFINITY, formatter: getNetworkLevel, + searchBy: getNetworkLevel, additionalFields: ["this.networkLevel"], }, historical: { diff --git a/packages/schemas/src/player/gamemodes/megawalls/kit.ts b/packages/schemas/src/player/gamemodes/megawalls/kit.ts index bfd11d607..4008ba5d7 100644 --- a/packages/schemas/src/player/gamemodes/megawalls/kit.ts +++ b/packages/schemas/src/player/gamemodes/megawalls/kit.ts @@ -6,7 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { type APIData, formatTime } from "@statsify/util"; +import { type APIData, formatTime, parseTime } from "@statsify/util"; import { Field } from "#metadata"; import { add, ratio } from "@statsify/math"; @@ -47,7 +47,7 @@ export class MegaWallsKit { public assists: number; @Field({ - leaderboard: { limit, formatter: formatTime }, + leaderboard: { limit, formatter: formatTime, parser: parseTime }, historical: { enabled: false }, }) public playtime: number; @@ -125,7 +125,7 @@ export class MegaWallsOverall extends MegaWallsKit { @Field() public declare assists: number; - @Field({ leaderboard: { formatter: formatTime } }) + @Field({ leaderboard: { formatter: formatTime, parser: parseTime } }) public declare playtime: number; public constructor(data: APIData) { diff --git a/packages/schemas/src/player/gamemodes/murdermystery/mode.ts b/packages/schemas/src/player/gamemodes/murdermystery/mode.ts index 3fe4ad413..76110e4a0 100644 --- a/packages/schemas/src/player/gamemodes/murdermystery/mode.ts +++ b/packages/schemas/src/player/gamemodes/murdermystery/mode.ts @@ -6,7 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { type APIData, formatTime, prettify } from "@statsify/util"; +import { type APIData, formatTime, parseTime, prettify } from "@statsify/util"; import { Field } from "#metadata"; import { Progression } from "#progression"; import { ratio } from "@statsify/math"; @@ -98,13 +98,13 @@ export class OverallMurderMysteryMode extends StandardMurderMysteryMode { export class ClassicMurderMysteryMode extends StandardMurderMysteryMode { @Field({ - leaderboard: { sort: "ASC", formatter: formatTime }, + leaderboard: { sort: "ASC", formatter: formatTime, parser: parseTime }, historical: { enabled: false }, }) public fastestDetectiveWin: number; @Field({ - leaderboard: { sort: "ASC", formatter: formatTime }, + leaderboard: { sort: "ASC", formatter: formatTime, parser: parseTime }, historical: { enabled: false }, }) public fastestMurdererWin: number; diff --git a/packages/schemas/src/player/gamemodes/parkour/index.ts b/packages/schemas/src/player/gamemodes/parkour/index.ts index 64948b0f4..00361dbd4 100644 --- a/packages/schemas/src/player/gamemodes/parkour/index.ts +++ b/packages/schemas/src/player/gamemodes/parkour/index.ts @@ -6,7 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { type APIData, formatTime } from "@statsify/util"; +import { type APIData, formatTime, parseTime } from "@statsify/util"; import { type ExtractGameModes, FormattedGame, GameModes } from "#game"; import { Field } from "#metadata"; @@ -14,7 +14,7 @@ export const PARKOUR_MODES = new GameModes([{ api: "overall" }] as const); export type ParkourModes = ExtractGameModes; -const fieldOptions = { sort: "ASC", formatter: formatTime, fieldName: "Time" }; +const fieldOptions = { sort: "ASC", formatter: formatTime, parser: parseTime, fieldName: "Time" }; const historical = { enabled: false }; export class Parkour { diff --git a/packages/schemas/src/player/gamemodes/pit/index.ts b/packages/schemas/src/player/gamemodes/pit/index.ts index 8e9d1804f..a3f3dc3f2 100644 --- a/packages/schemas/src/player/gamemodes/pit/index.ts +++ b/packages/schemas/src/player/gamemodes/pit/index.ts @@ -6,7 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { type APIData, formatTime } from "@statsify/util"; +import { type APIData, formatTime, parseTime } from "@statsify/util"; import { type ExtractGameModes, GameModes } from "#game"; import { Field } from "#metadata"; import { Progression } from "#progression"; @@ -40,6 +40,7 @@ export class Pit { const level = getLevel(prestige, exp); return getLevelFormatted(level, prestige); }, + searchBy: getPrestige, }, historical: { hidden: false, @@ -124,7 +125,7 @@ export class Pit { }) public totalMysticsEnchanted: number; - @Field({ leaderboard: { formatter: formatTime }, historical: { enabled: false } }) + @Field({ leaderboard: { formatter: formatTime, parser: parseTime }, historical: { enabled: false } }) public playtime: number; @Field({ historical: { enabled: false } }) diff --git a/packages/schemas/src/player/gamemodes/skywars/index.ts b/packages/schemas/src/player/gamemodes/skywars/index.ts index 1de1e1186..8575052d5 100644 --- a/packages/schemas/src/player/gamemodes/skywars/index.ts +++ b/packages/schemas/src/player/gamemodes/skywars/index.ts @@ -44,6 +44,7 @@ export class SkyWars { fieldName: "Level", hidden: true, formatter: (exp: number) => getLevel(exp), + searchBy: getLevel, additionalFields: ["this.overall.wins", "this.overall.kills", "this.overall.kdr"], }, historical: { diff --git a/packages/schemas/src/player/gamemodes/skywars/mode.ts b/packages/schemas/src/player/gamemodes/skywars/mode.ts index 6ff966260..6142f74e7 100644 --- a/packages/schemas/src/player/gamemodes/skywars/mode.ts +++ b/packages/schemas/src/player/gamemodes/skywars/mode.ts @@ -6,7 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { type APIData, formatTime } from "@statsify/util"; +import { type APIData, formatTime, parseTime } from "@statsify/util"; import { Field } from "#metadata"; import { add, ratio, sub } from "@statsify/math"; @@ -35,7 +35,7 @@ export class SkyWarsMini implements SkyWarsMode { @Field() public assists: number; - @Field({ leaderboard: { formatter: formatTime }, historical: { enabled: false } }) + @Field({ leaderboard: { formatter: formatTime, parser: parseTime }, historical: { enabled: false } }) public playtime: number; // Kit gets applied in the main class @@ -89,7 +89,7 @@ export class SkyWarsMode { @Field() public assists: number; - @Field({ leaderboard: { formatter: formatTime }, historical: { enabled: false } }) + @Field({ leaderboard: { formatter: formatTime, parser: parseTime }, historical: { enabled: false } }) public playtime: number; // Kit gets applied in the main class diff --git a/packages/schemas/src/player/gamemodes/tntgames/mode.ts b/packages/schemas/src/player/gamemodes/tntgames/mode.ts index abb824ba2..876c799da 100644 --- a/packages/schemas/src/player/gamemodes/tntgames/mode.ts +++ b/packages/schemas/src/player/gamemodes/tntgames/mode.ts @@ -6,7 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { type APIData, formatTime } from "@statsify/util"; +import { type APIData, formatTime, parseTime } from "@statsify/util"; import { Field } from "#metadata"; import { type GamePrefix, createPrefixProgression, cycleColors, defaultPrefix, getFormattedPrefix } from "#prefixes"; import { Progression } from "#progression"; @@ -95,7 +95,7 @@ export class PVPRun { @Field() public kdr: number; - @Field({ leaderboard: { formatter: formatTime }, historical: { enabled: false } }) + @Field({ leaderboard: { formatter: formatTime, parser: parseTime }, historical: { enabled: false } }) public record: number; @Field() @@ -149,7 +149,7 @@ export class TNTRun { @Field() public wlr: number; - @Field({ leaderboard: { formatter: formatTime }, historical: { enabled: false } }) + @Field({ leaderboard: { formatter: formatTime, parser: parseTime }, historical: { enabled: false } }) public record: number; @Field() @@ -319,7 +319,7 @@ export class Wizards { @Field() public points: number; - @Field({ leaderboard: { formatter: formatTime } }) + @Field({ leaderboard: { formatter: formatTime, parser: parseTime } }) public airTime: number; @Field() diff --git a/packages/schemas/src/player/gamemodes/woolgames/capture-the-wool.ts b/packages/schemas/src/player/gamemodes/woolgames/capture-the-wool.ts index 477c45511..13ac99c29 100644 --- a/packages/schemas/src/player/gamemodes/woolgames/capture-the-wool.ts +++ b/packages/schemas/src/player/gamemodes/woolgames/capture-the-wool.ts @@ -6,7 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { type APIData, formatTime } from "@statsify/util"; +import { type APIData, formatTime, parseTime } from "@statsify/util"; import { Field } from "#metadata"; import { ratio } from "@statsify/math"; @@ -45,6 +45,7 @@ export class CaptureTheWool { leaderboard: { sort: "ASC", formatter: formatTime, + parser: parseTime, additionalFields: ["this.wins"], }, historical: { enabled: false }, @@ -55,6 +56,7 @@ export class CaptureTheWool { leaderboard: { sort: "ASC", formatter: formatTime, + parser: parseTime, additionalFields: ["this.woolCaptured"], }, historical: { enabled: false }, @@ -62,7 +64,7 @@ export class CaptureTheWool { public fastestWoolCapture: number; @Field({ - leaderboard: { formatter: formatTime }, + leaderboard: { formatter: formatTime, parser: parseTime }, historical: { enabled: false }, }) public longestGame: number; diff --git a/packages/schemas/src/player/gamemodes/woolgames/index.ts b/packages/schemas/src/player/gamemodes/woolgames/index.ts index 18d30b091..973538bbb 100644 --- a/packages/schemas/src/player/gamemodes/woolgames/index.ts +++ b/packages/schemas/src/player/gamemodes/woolgames/index.ts @@ -6,7 +6,7 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { type APIData, formatTime } from "@statsify/util"; +import { type APIData, formatTime, parseTime } from "@statsify/util"; import { CaptureTheWool } from "./capture-the-wool.js"; import { type ExtractGameModes, GameModes } from "#game"; import { Field } from "#metadata"; @@ -50,6 +50,7 @@ export class WoolGames { fieldName: "Level", hidden: true, formatter: (exp: number) => getFormattedLevel(Math.floor(getLevel(exp))), + searchBy: getLevel, additionalFields: ["this.wins", "this.playtime"], }, historical: { @@ -73,7 +74,7 @@ export class WoolGames { @Field() public nextLevelFormatted: string; - @Field({ leaderboard: { formatter: formatTime }, historical: { enabled: false } }) + @Field({ leaderboard: { formatter: formatTime, parser: parseTime }, historical: { enabled: false } }) public playtime: number; @Field() diff --git a/packages/schemas/src/util/index.ts b/packages/schemas/src/util/index.ts index 87b5d83fc..b7b584104 100644 --- a/packages/schemas/src/util/index.ts +++ b/packages/schemas/src/util/index.ts @@ -7,4 +7,5 @@ */ export * from "./leaderboard-scanner.js"; +export * from "./parse-leaderboard-value.js"; export * from "./create-historical-player.js"; diff --git a/packages/schemas/src/util/parse-leaderboard-value.ts b/packages/schemas/src/util/parse-leaderboard-value.ts new file mode 100644 index 000000000..cfa7e1902 --- /dev/null +++ b/packages/schemas/src/util/parse-leaderboard-value.ts @@ -0,0 +1,50 @@ +/** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + +import type { Constructor } from "@statsify/util"; +import { getLeaderboardField } from "./leaderboard-scanner.js"; + +const SUFFIXES: Record = { k: 1e3, m: 1e6, b: 1e9 }; + +function getLowestScore(searchBy: (score: number) => number, value: number) { + let low = 0; + let high = Number.MAX_SAFE_INTEGER; + + if (searchBy(high) < value) return Number.NaN; + + while (low < high) { + const mid = low + Math.floor((high - low) / 2); + + if (searchBy(mid) < value) low = mid + 1; + else high = mid; + } + + return low; +} + +/** + * @returns The score a search like `10k`, `500✫` or `1:23.45` sorts at, or NaN if it can't be read + */ +export function parseLeaderboardValue( + constructor: Constructor, + key: string, + input: string, +) { + const { parser, searchBy } = getLeaderboardField(constructor, key); + const search = input.replaceAll(",", "").trim().toLowerCase(); + + if (parser) return parser(search); + + const match = /^(\d*\.?\d+)\s*([kmb])?/.exec(search); + if (!match) return Number.NaN; + + const [, number, suffix] = match; + const value = Number(number) * (suffix ? SUFFIXES[suffix] : 1); + + return searchBy ? getLowestScore(searchBy, value) : value; +} diff --git a/packages/util/src/util.ts b/packages/util/src/util.ts index ea19a0eb5..1f1659437 100644 --- a/packages/util/src/util.ts +++ b/packages/util/src/util.ts @@ -183,6 +183,27 @@ export const relativeTime = (time: number) => export const formatRaceTime = (timeMs: number) => `${(timeMs / 1000).toFixed(3)}s`; +const TIME_UNITS: Record = { + ms: 1, + s: MS, + m: MS * SECONDS, + h: MS * SECONDS * MINUTES, + d: MS * SECONDS * MINUTES * HOURS, + mo: MS * SECONDS * MINUTES * HOURS * MONTHS, + y: MS * SECONDS * MINUTES * HOURS * MONTHS * YEARS, +}; + +// Reads formatTime or formatRaceTime output, or a time like 1:23.45, back into milliseconds +export const parseTime = (input: string) => { + const units = [...input.matchAll(/(\d*\.?\d+)\s*(mo|ms|[ydhms])/g)]; + + const time = units.length > 0 ? + units.reduce((total, [, number, unit]) => total + Number(number) * TIME_UNITS[unit], 0) : + input.split(":").reduce((total, part) => total * 60 + Number(part), 0) * MS; + + return Math.round(time); +}; + export const formatRecordTime = (timeMs: number) => timeMs ? formatTime(timeMs) : "N/A";