Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/api/src/dtos/guild-leaderboard.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Guild, getLeaderboardFields } from "@statsify/schemas";
import {
IsEnum,
IsInt,
IsNumber,
IsOptional,
IsString,
MaxLength,
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion apps/api/src/dtos/player-leaderboard.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
38 changes: 35 additions & 3 deletions apps/api/src/leaderboards/leaderboard.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -302,6 +313,27 @@ export abstract class LeaderboardService {
return response;
}

private async searchLeaderboardValue<T>(
constructor: Constructor<T>,
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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 =
Expand All @@ -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,
Expand Down Expand Up @@ -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),
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
};

Expand Down
14 changes: 13 additions & 1 deletion locales/en-US/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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": {
Expand Down
3 changes: 2 additions & 1 deletion locales/en-US/emojis.json
Original file line number Diff line number Diff line change
Expand Up @@ -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>",
Expand Down
3 changes: 2 additions & 1 deletion packages/api-client/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,6 @@ export enum CacheLevel {
export enum LeaderboardQuery {
PAGE = "page",
INPUT = "input",
POSITION = "position"
POSITION = "position",
VALUE = "value"
}
8 changes: 7 additions & 1 deletion packages/schemas/src/guild/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ export const getLeaderboardMetadata = (
limit:
leaderboardOptions?.limit ?? getDefaultLeaderboardLimit(propertyKey),
resetEvery: leaderboardOptions?.resetEvery,
parser: leaderboardOptions?.parser,
searchBy: leaderboardOptions?.searchBy,
};
}

Expand Down
10 changes: 10 additions & 0 deletions packages/schemas/src/metadata/metadata.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Loading
Loading