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..2e52b4235 100644 --- a/apps/api/src/dtos/player-leaderboard.dto.ts +++ b/apps/api/src/dtos/player-leaderboard.dto.ts @@ -7,8 +7,8 @@ */ import { ApiProperty, PartialType } from "@nestjs/swagger"; -import { IsEnum, IsInt, IsOptional, Min } from "class-validator"; -import { Player, getLeaderboardFields } from "@statsify/schemas"; +import { IsEnum, IsInt, IsNumber, IsOptional, Min } from "class-validator"; +import { getLeaderboardFields, Player } 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..0e2edccb8 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 (typeof value === "number") { + 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..b5c4f2140 100644 --- a/apps/api/src/leaderboards/leaderboard.service.ts +++ b/apps/api/src/leaderboards/leaderboard.service.ts @@ -117,6 +117,18 @@ export abstract class LeaderboardService { bottom = top + PAGE_SIZE; break; } + case LeaderboardQuery.VALUE: { + const ranking = await this.searchLeaderboardValue( + constructor, + field, + input as number, + sort + ); + highlight = ranking - 1; + top = highlight - (highlight % 10); + bottom = top + PAGE_SIZE; + break; + } } const leaderboard = await this.getLeaderboardFromRedis( @@ -302,6 +314,33 @@ export abstract class LeaderboardService { return response; } + private async searchLeaderboardValue( + constructor: Constructor, + field: string, + value: number, + sort = "DESC" + ): Promise { + const name = constructor.name.toLowerCase(); + const key = `${name}.${field}`; + + const result = sort === "ASC" ? + await this.redis.zrangebyscore(key, value, "+inf", "LIMIT", 0, 1) : + await this.redis.zrevrangebyscore(key, value, "-inf", "LIMIT", 0, 1); + + const fallback = sort === "ASC" ? + await this.redis.zrevrange(key, 0, 0) : + await this.redis.zrange(key, 0, 0); + + const id = result[0] ?? fallback[0]; + if (!id) return 1; + + const rank = sort === "ASC" ? + await this.redis.zrank(key, id) : + await this.redis.zrevrank(key, id); + + return (rank ?? 0) + 1; + } + 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..c2f6ba641 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 (typeof value === "number") { + 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 a2d2e1868..d743c4ace 100644 --- a/apps/discord-bot/src/commands/leaderboards/base.leaderboard-command.tsx +++ b/apps/discord-bot/src/commands/leaderboards/base.leaderboard-command.tsx @@ -48,6 +48,33 @@ type GetLeaderboard = ( type GetLeaderboardDataIcon = (id: string) => Promise; +const parseLeaderboardValue = (input: string): number => { + const normalized = input.trim().toLowerCase().replaceAll("_", ""); + + const isEuropean = /\d{1,3}(\.\d{3})+(,\d+)?$/.test(normalized.replace(/^-/, "")); + + const sanitized = isEuropean ? + normalized.replaceAll(".", "").replace(",", ".") : + normalized.replaceAll(",", ""); + + const match = sanitized.match(/^(-?\d+(?:\.\d+)?)(?:e([+-]?\d+))?([kmbt])?$/); + if (!match) return Number.NaN; + + const suffixes = { + k: 1000, + m: 1_000_000, + b: 1_000_000_000, + t: 1_000_000_000_000, + }; + + const [, value, exponent, suffix] = match; + const base = exponent ? Number(value) * Math.pow(10, Number(exponent)) : Number(value); + const multiplier = suffix ? suffixes[suffix as keyof typeof suffixes] : 1; + const result = base * multiplier; + + return result === 0 ? 0 : result; +}; + export interface CreateLeaderboardOptions { context: CommandContext; background: Image; @@ -90,10 +117,17 @@ export class BaseLeaderboardCommand { const searchDocument = new ButtonBuilder() .emoji(t(`emojis:search.${type}`)) + .label((t) => t(`leaderboard.${type}Input.button`)) .style(ButtonStyle.Primary); const searchPosition = new ButtonBuilder() .emoji(t("emojis:search.position")) + .label((t) => t("leaderboard.positionInput.button")) + .style(ButtonStyle.Primary); + + const searchValue = new ButtonBuilder() + .emoji(t("emojis:search.value")) + .label((t) => t("leaderboard.valueInput.button")) .style(ButtonStyle.Primary); let currentPage = 0; @@ -117,8 +151,14 @@ export class BaseLeaderboardCommand { if (interaction.getUserId() === userId && !message.ephemeral) { up.disable(page === 0); - currentPage = page || currentPage; - const row = new ActionRowBuilder([up, down, searchDocument, searchPosition]); + currentPage = page ?? currentPage; + const row = new ActionRowBuilder([ + up, + down, + searchDocument, + searchPosition, + searchValue, + ]); context.reply({ ...message, @@ -174,6 +214,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(20) + .required(true) + ) + ); + listener.addHook(searchDocument.getCustomId(), () => ({ type: InteractionResponseType.Modal, data: documentModal.build(t), @@ -184,6 +237,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; @@ -214,7 +272,29 @@ 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(valueInput); + + if (user?.locale) interaction.setLocale(user.locale); + + if (Number.isNaN(value) || value < 0) { + const error = new ErrorMessage("errors.leaderboardInvalidValue"); + + return interaction.sendFollowup({ + ...error, + ephemeral: true, + }); + } + + changePage(() => ({ input: value, type: LeaderboardQuery.VALUE }))( + interaction + ); + }); + + const row = new ActionRowBuilder([up, down, searchDocument, searchPosition, searchValue]); const [message, page] = await this.getLeaderboardMessage( user, @@ -234,8 +314,10 @@ export class BaseLeaderboardCommand { 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(); diff --git a/locales/en-US/default.json b/locales/en-US/default.json index b66f3e884..4e01ddf04 100644 --- a/locales/en-US/default.json +++ b/locales/en-US/default.json @@ -427,6 +427,10 @@ "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 positive number. Suffixes like k, m, b, and t are supported.", + "title": "Invalid Value" + }, "leaderboardNotFound": { "description": "We can't find the `leaderboard` you are looking for!", "title": "Leaderboard Not Found" @@ -525,6 +529,7 @@ }, "leaderboard": { "guildInput": { + "button": "Guild Name", "label": "Search by a Guild", "placeholder": "Enter a guild's name, eg: Quote" }, @@ -532,12 +537,19 @@ "title": "Leaderboard Search" }, "playerInput": { + "button": "Username", "label": "Search by a Player", "placeholder": "Enter a player's name, eg: j4cobi" }, "positionInput": { + "button": "Position", "label": "Search by a Position", "placeholder": "Enter a position, eg: 5000" + }, + "valueInput": { + "button": "Value", + "label": "Search by a Value", + "placeholder": "Enter a value, eg: 5k, 15m, 50b" } }, "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" }