From e7d8e73fe46950b772b3876220e707435c169ab2 Mon Sep 17 00:00:00 2001 From: Cody Date: Tue, 2 Jun 2026 22:12:24 -0600 Subject: [PATCH] feat(rankings): add guild leaderboard filtering * Introduce PlayerLeaderboardGuildArgument for guild filtering * Update rankings command to include guild option * Implement guild-specific player rankings retrieval * Add validation to restrict guild access to diamond tier users * Enhance player argument autocomplete to handle empty queries * Add new API responses for guild search functionality * Update localization files to include new guild filter descriptions --- .DS_Store | Bin 0 -> 8196 bytes .gitmodules | 2 +- apps/api/src/dtos/guild-search.dto.ts | 18 + apps/api/src/dtos/index.ts | 1 + apps/api/src/dtos/player-leaderboard.dto.ts | 10 +- apps/api/src/dtos/player-rankings.dto.ts | 7 +- apps/api/src/dtos/player-search.dto.ts | 7 +- apps/api/src/guild/guild.module.ts | 6 +- apps/api/src/guild/guild.service.ts | 16 +- .../guild/search/guild-search.controller.ts | 33 ++ .../src/guild/search/guild-search.service.ts | 87 ++++ .../player-leaderboard.controller.ts | 46 +- .../player-leaderboard.service.ts | 392 +++++++++++++++--- apps/api/src/player/player.module.ts | 4 +- .../player/search/player-search.controller.ts | 2 +- .../player/search/player-search.service.ts | 91 ++-- .../leaderboards/base.leaderboard-command.tsx | 37 +- .../guild-leaderboard.subcommand.ts | 2 +- .../player-leaderboard.argument.ts | 55 ++- .../player-leaderboard.command.ts | 67 +-- .../commands/rankings/rankings.command.tsx | 14 +- locales/bg/default.json | 1 + locales/cs/default.json | 1 + locales/da/default.json | 1 + locales/de/default.json | 1 + locales/el/default.json | 1 + locales/en-US/default.json | 1 + locales/es-ES/default.json | 1 + locales/fi/default.json | 1 + locales/fr/default.json | 1 + locales/hi/default.json | 1 + locales/hr/default.json | 1 + locales/hu/default.json | 1 + locales/it/default.json | 1 + locales/ja/default.json | 1 + locales/ko/default.json | 1 + locales/lt/default.json | 1 + locales/nl/default.json | 1 + locales/no/default.json | 1 + locales/pl/default.json | 1 + locales/pt-BR/default.json | 1 + locales/ro/default.json | 1 + locales/ru/default.json | 1 + locales/sv-SE/default.json | 1 + locales/th/default.json | 1 + locales/tr/default.json | 1 + locales/uk/default.json | 1 + locales/vi/default.json | 1 + locales/zh-CN/default.json | 1 + locales/zh-TW/default.json | 1 + packages/api-client/src/api.service.ts | 87 +++- .../responses/get.guild-search.response.ts | 15 + packages/api-client/src/responses/index.ts | 1 + .../responses/post.leaderboard.response.ts | 2 + .../discord/src/arguments/player.argument.ts | 8 +- pnpm-lock.yaml | 16 +- 56 files changed, 884 insertions(+), 171 deletions(-) create mode 100644 .DS_Store create mode 100644 apps/api/src/dtos/guild-search.dto.ts create mode 100644 apps/api/src/guild/search/guild-search.controller.ts create mode 100644 apps/api/src/guild/search/guild-search.service.ts create mode 100644 packages/api-client/src/responses/get.guild-search.response.ts diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..40afe69b63d8d9a158c87891be539909ade4ba67 GIT binary patch literal 8196 zcmeI1&1(}u7{=c*iw#(%P?a9{Q0&bbP=wOLwnhX`LcA5J&4+0*aYNJ8iXeONIp7>{4mby#1I~d_Z~$wz zEXAC4UuRwI9B>Z&mk!A1gO5YTz{E&L_2@vQwg8A8E~|ob>;qIA-^9SgNJrHaed^tV zFinNoVhG(Fd0)yA0}~@1b#oHBISDheFgp~XMn^ke$w>q{y4pG597sDL_wGfSq4Ts! zJ-&ahh3$@(pPJ6nw8L__+6t>O_hkO%qp#mSE@tcML+LterAjiUc!93yB&h1tpe?H5 zt-?JJuF^VUgEr(FhD1%?)4^)aI(su=9ngO}>{Acd!s6EtFAw}p5N~qY!mYV;piOd| zZRhv(vk$*3A6dRnl9lJ<7Iq?ZO=I!6w1zpY^3jRr_tOf_@a%Kq&!K*}C;2MQL$n+7 zY*AOuBIeWPnfzpUn1b_6FTRTJEy-i4`p)F5Y@R^!c({)`_;l;tTB{m{E$gC%SKoWf z8*s3LgZD95(jm7fD{sMU%VFs_V7mA;aC;{}a*?wV7L;md=Q-G!xeqaz)dz>m(^!Cd zoFdS+0ojIQEu42Pmd5Kpkfk)7$6wx!c`|hUIUJ<3xb9q#WMiNSaZjygP)+`LoK|qA zH}Ag0Oo#g6F6FB%p3~|sAIFm^RL`pwcqPwzX|jd!2P+`U)pExD=j&JQ>!-m0ec!)f z^ql+J z;6Oq5F(vo^SM%ThAHhhj3+I4yU{oCtUb#|U0-f1gr#R(a+sAPmhb&ST>8P5ZQrmGv x50_;=`N0t9zPT2d80m-%%0GV*pmr?zD?4A5@>SN7 Number, required: false }) public position?: number; } + +export class GuildScopedPlayerLeaderboardDto extends PlayerLeaderboardDto { + @IsString() + @MinLength(3) + @MaxLength(32) + @ApiProperty() + public guild: string; +} diff --git a/apps/api/src/dtos/player-rankings.dto.ts b/apps/api/src/dtos/player-rankings.dto.ts index 71058292d..a67e39159 100644 --- a/apps/api/src/dtos/player-rankings.dto.ts +++ b/apps/api/src/dtos/player-rankings.dto.ts @@ -7,7 +7,7 @@ */ import { ApiProperty } from "@nestjs/swagger"; -import { IsEnum } from "class-validator"; +import { IsEnum, IsOptional, IsString } from "class-validator"; import { LeaderboardScanner, Player } from "@statsify/schemas"; import { UuidDto } from "./uuid.dto.js"; @@ -17,4 +17,9 @@ export class PlayerRankingsDto extends UuidDto { @ApiProperty({ enum: fields, type: [String] }) @IsEnum(fields, { each: true }) public fields: string[]; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + public guild?: string; } diff --git a/apps/api/src/dtos/player-search.dto.ts b/apps/api/src/dtos/player-search.dto.ts index a6c1db39c..9cdc31380 100644 --- a/apps/api/src/dtos/player-search.dto.ts +++ b/apps/api/src/dtos/player-search.dto.ts @@ -7,11 +7,12 @@ */ import { ApiProperty } from "@nestjs/swagger"; -import { IsString, MaxLength } from "class-validator"; +import { IsOptional, IsString, MaxLength } from "class-validator"; export class PlayerSearchDto { + @IsOptional() @IsString() @MaxLength(16) - @ApiProperty() - public query: string; + @ApiProperty({ required: false }) + public query?: string; } diff --git a/apps/api/src/guild/guild.module.ts b/apps/api/src/guild/guild.module.ts index 01c267dc0..57234a91b 100644 --- a/apps/api/src/guild/guild.module.ts +++ b/apps/api/src/guild/guild.module.ts @@ -10,6 +10,8 @@ import { Guild, Player } from "@statsify/schemas"; import { GuildController } from "./guild.controller.js"; import { GuildLeaderboardController } from "./leaderboards/guild-leaderboard.controller.js"; import { GuildLeaderboardService } from "./leaderboards/guild-leaderboard.service.js"; +import { GuildSearchController } from "./search/guild-search.controller.js"; +import { GuildSearchService } from "./search/guild-search.service.js"; import { GuildService } from "./guild.service.js"; import { HypixelModule } from "#hypixel"; import { Module } from "@nestjs/common"; @@ -18,7 +20,7 @@ import { TypegooseModule } from "@m8a/nestjs-typegoose"; @Module({ imports: [HypixelModule, PlayerModule, TypegooseModule.forFeature([Guild, Player])], - controllers: [GuildController, GuildLeaderboardController], - providers: [GuildService, GuildLeaderboardService], + controllers: [GuildController, GuildLeaderboardController, GuildSearchController], + providers: [GuildService, GuildLeaderboardService, GuildSearchService], }) export class GuildModule {} diff --git a/apps/api/src/guild/guild.service.ts b/apps/api/src/guild/guild.service.ts index a13129c3d..9dc4f666c 100644 --- a/apps/api/src/guild/guild.service.ts +++ b/apps/api/src/guild/guild.service.ts @@ -14,6 +14,7 @@ import { } from "@statsify/api-client"; import { Guild, GuildMember, Player, deserialize, serialize } from "@statsify/schemas"; import { GuildLeaderboardService } from "./leaderboards/guild-leaderboard.service.js"; +import { GuildSearchService } from "./search/guild-search.service.js"; import { HypixelService } from "#hypixel"; import { InjectModel } from "@m8a/nestjs-typegoose"; import { Injectable } from "@nestjs/common"; @@ -30,6 +31,7 @@ export class GuildService { private readonly hypixelService: HypixelService, private readonly playerService: PlayerService, private readonly guildLeaderboardService: GuildLeaderboardService, + private readonly guildSearchService: GuildSearchService, @InjectModel(Guild) private readonly guildModel: ReturnModelType, @InjectModel(Player) private readonly playerModel: ReturnModelType ) {} @@ -149,12 +151,14 @@ export class GuildService { const flatGuild = flatten(guild); const serializedGuild = serialize(Guild, flatGuild); - await this.guildModel - .replaceOne({ id: guild.id }, serializedGuild, { upsert: true }) - .lean() - .exec(); - - await this.guildLeaderboardService.addLeaderboards(Guild, serializedGuild, "id"); + await Promise.all([ + this.guildModel + .replaceOne({ id: guild.id }, serializedGuild, { upsert: true }) + .lean() + .exec(), + this.guildLeaderboardService.addLeaderboards(Guild, serializedGuild, "id"), + this.guildSearchService.add(guild.name), + ]); return deserialize(Guild, flatGuild); } diff --git a/apps/api/src/guild/search/guild-search.controller.ts b/apps/api/src/guild/search/guild-search.controller.ts new file mode 100644 index 000000000..6730963b0 --- /dev/null +++ b/apps/api/src/guild/search/guild-search.controller.ts @@ -0,0 +1,33 @@ +/** + * 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 { ApiOkResponse, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Auth } from "#auth"; +import { Controller, Get, Query } from "@nestjs/common"; +import { GetGuildSearchResponse } from "@statsify/api-client"; +import { GuildSearchDto } from "#dtos"; +import { GuildSearchService } from "./guild-search.service.js"; + +@Controller("/guild/search") +@ApiTags("Guild") +export class GuildSearchController { + public constructor(private readonly guildSearchService: GuildSearchService) {} + + @ApiOperation({ summary: "Get a Fuzzy Searched List of Guilds" }) + @ApiOkResponse({ type: GetGuildSearchResponse }) + @Auth({ weight: 0 }) + @Get() + public async getGuilds(@Query() { query = "" }: GuildSearchDto) { + const guilds = await this.guildSearchService.get(query); + + return { + success: true, + guilds, + }; + } +} diff --git a/apps/api/src/guild/search/guild-search.service.ts b/apps/api/src/guild/search/guild-search.service.ts new file mode 100644 index 000000000..e7019cc3e --- /dev/null +++ b/apps/api/src/guild/search/guild-search.service.ts @@ -0,0 +1,87 @@ +/** + * 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 { Guild } from "@statsify/schemas"; +import { InjectModel } from "@m8a/nestjs-typegoose"; +import { InjectRedis } from "#redis"; +import { Injectable } from "@nestjs/common"; +import { Logger } from "@statsify/logger"; +import { Redis } from "ioredis"; +import type { ReturnModelType } from "@typegoose/typegoose"; + +const REDI_SEARCH_NOT_INSTALLED = + "This error was most likely caused because RediSearch is not installed."; +const GUILD_AUTOCOMPLETE_KEY = "guild:autocomplete"; + +@Injectable() +export class GuildSearchService { + private logger = new Logger("GuildSearchService"); + + public constructor( + @InjectRedis() private readonly redis: Redis, + @InjectModel(Guild) private readonly guildModel: ReturnModelType + ) {} + + public async get(query: string): Promise { + query = query.trim().toLowerCase(); + + if (!query) return this.getCachedGuilds(); + + const searchOptions = query.length >= 3 ? ["FUZZY", "MAX", "25"] : ["MAX", "25"]; + + const redisResults = await ( + this.redis.call( + "FT.SUGGET", + GUILD_AUTOCOMPLETE_KEY, + query, + ...searchOptions + ) as Promise + ).catch((e) => { + this.handleRedisError(e); + + return []; + }); + + if (redisResults.length) return redisResults; + + return this.getCachedGuilds(query); + } + + public async add(name: string) { + if (name.length < 3 || name.length > 32) return; + + await this.redis + .call("FT.SUGADD", GUILD_AUTOCOMPLETE_KEY, name, "1", "INCR") + .catch((e) => this.handleRedisError(e)); + } + + private async getCachedGuilds(query?: string) { + const filter = query ? + { nameToLower: { $regex: `^${this.escapeRegex(query)}` } } : + {}; + + const guilds = await this.guildModel + .find(filter) + .select({ name: true, _id: false }) + .sort({ exp: -1 }) + .limit(25) + .lean() + .exec(); + + return guilds.map(({ name }) => name); + } + + private escapeRegex(input: string) { + return input.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); + } + + private handleRedisError(error: unknown) { + this.logger.error(error); + this.logger.error(REDI_SEARCH_NOT_INSTALLED); + } +} diff --git a/apps/api/src/player/leaderboards/player-leaderboard.controller.ts b/apps/api/src/player/leaderboards/player-leaderboard.controller.ts index 567d5f4cf..30de06f79 100644 --- a/apps/api/src/player/leaderboards/player-leaderboard.controller.ts +++ b/apps/api/src/player/leaderboards/player-leaderboard.controller.ts @@ -17,11 +17,16 @@ import { Body, Controller, Post } from "@nestjs/common"; import { ErrorResponse, LeaderboardQuery, + PostGuildScopedPlayerLeaderboardResponse, PostLeaderboardRankingsResponse, PostLeaderboardResponse, } from "@statsify/api-client"; +import { + GuildScopedPlayerLeaderboardDto, + PlayerLeaderboardDto, + PlayerRankingsDto, +} from "#dtos"; import { Player } from "@statsify/schemas"; -import { PlayerLeaderboardDto, PlayerRankingsDto } from "#dtos"; import { PlayerLeaderboardService } from "./player-leaderboard.service.js"; @Controller("/player/leaderboards") @@ -56,12 +61,49 @@ export class PlayerLeaderboardsController { return this.playerLeaderboardService.getLeaderboard(Player, field, input, type); } + @Post("/guild") + @ApiOperation({ summary: "Get a Guild Scoped Player Leaderboard" }) + @ApiOkResponse({ type: PostGuildScopedPlayerLeaderboardResponse }) + @ApiBadRequestResponse({ type: ErrorResponse }) + @Auth({ weight: 3 }) + public getGuildScopedPlayerLeaderboard( + @Body() { field, page, player, position, guild }: GuildScopedPlayerLeaderboardDto + ) { + let input: number | string; + let type: LeaderboardQuery; + + if (player) { + input = player; + type = LeaderboardQuery.INPUT; + } else if (position) { + input = position; + type = LeaderboardQuery.POSITION; + } else { + input = page; + type = LeaderboardQuery.PAGE; + } + + return this.playerLeaderboardService.getGuildScopedLeaderboard( + guild, + field, + input, + type + ); + } + @Post("/rankings") @ApiOperation({ summary: "Get a Player Rankings" }) @ApiOkResponse({ type: [PostLeaderboardRankingsResponse] }) @ApiBadRequestResponse({ type: ErrorResponse }) @Auth({ weight: 5 }) - public async getPlayerRankings(@Body() { fields, uuid }: PlayerRankingsDto) { + public async getPlayerRankings(@Body() { fields, guild, uuid }: PlayerRankingsDto) { + if (guild) + return this.playerLeaderboardService.getGuildScopedLeaderboardRankings( + guild, + fields, + uuid + ); + return this.playerLeaderboardService.getLeaderboardRankings(Player, fields, uuid); } } diff --git a/apps/api/src/player/leaderboards/player-leaderboard.service.ts b/apps/api/src/player/leaderboards/player-leaderboard.service.ts index 5877de527..08200e40d 100644 --- a/apps/api/src/player/leaderboards/player-leaderboard.service.ts +++ b/apps/api/src/player/leaderboards/player-leaderboard.service.ts @@ -6,71 +6,337 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { CacheLevel, PlayerNotFoundException } from "@statsify/api-client"; -import { type Circular, flatten } from "@statsify/util"; -import { Inject, Injectable, forwardRef } from "@nestjs/common"; -import { InjectModel } from "@m8a/nestjs-typegoose"; -import { InjectRedis } from "#redis"; -import { LeaderboardAdditionalStats, LeaderboardService } from "#leaderboards"; -import { Player } from "@statsify/schemas"; -import { PlayerService } from "#player"; -import { Redis } from "ioredis"; -import type { ReturnModelType } from "@typegoose/typegoose"; +import { + CacheLevel, + GUILD_ID_REGEX, + GuildNotFoundException, + LeaderboardQuery, + PlayerNotFoundException, +} from "@statsify/api-client"; +import { type Circular, flatten } from "@statsify/util"; +import { Guild, LeaderboardScanner, Player, serialize } from "@statsify/schemas"; +import { HypixelService } from "#hypixel"; +import { Inject, Injectable, forwardRef } from "@nestjs/common"; +import { InjectModel } from "@m8a/nestjs-typegoose"; +import { InjectRedis } from "#redis"; +import { LeaderboardAdditionalStats, LeaderboardService } from "#leaderboards"; +import { PlayerService } from "#player"; +import { Redis } from "ioredis"; +import type { ReturnModelType } from "@typegoose/typegoose"; @Injectable() export class PlayerLeaderboardService extends LeaderboardService { - public constructor( - @Inject(forwardRef(() => PlayerService)) - private readonly playerService: Circular, - @InjectModel(Player) private readonly playerModel: ReturnModelType, - @InjectRedis() redis: Redis - ) { - super(redis); - } - - protected async searchLeaderboardInput(input: string, field: string): Promise { - if (input.length <= 16) { - const player = await this.playerService.get(input, CacheLevel.CACHE_ONLY, { - uuid: true, - }); - - if (!player) throw new PlayerNotFoundException(); - input = player.uuid; - } - - const ranking = await this.getLeaderboardRankings(Player, [field], input); + public constructor( + @Inject(forwardRef(() => PlayerService)) + private readonly playerService: Circular, + private readonly hypixelService: HypixelService, + @InjectModel(Player) private readonly playerModel: ReturnModelType, + @InjectModel(Guild) private readonly guildModel: ReturnModelType, + @InjectRedis() redis: Redis + ) { + super(redis); + } + + public async getGuildScopedLeaderboard( + guildInput: string, + field: string, + input: number | string, + type: LeaderboardQuery + ) { + const PAGE_SIZE = 10; + + const { + fieldName, + additionalFields = [], + extraDisplay, + formatter, + sort, + name, + hidden, + } = LeaderboardScanner.getLeaderboardField(Player, field); + + const guild = await this.getGuild(guildInput); + const memberIds = guild.members.map(({ uuid }) => uuid); + + const key = `player.${field}`; + const scores = await this.redis.call("ZMSCORE", key, ...memberIds) as (string | null)[]; + + const leaderboard = scores + .map((score, index) => (score === null ? + null : + { + id: memberIds[index], + score: Number(score), + })) + .filter((score): score is { id: string; score: number } => score !== null) + .sort((a, b) => sort === "ASC" ? a.score - b.score : b.score - a.score) + .map((doc, index) => ({ ...doc, index })); + + let top: number; + let bottom: number; + let highlight: number | undefined = undefined; + + switch (type) { + case LeaderboardQuery.PAGE: + top = (input as number) * PAGE_SIZE; + bottom = top + PAGE_SIZE; + break; + + case LeaderboardQuery.INPUT: { + const playerId = await this.getPlayerId(input as string); + highlight = leaderboard.findIndex(({ id }) => id === playerId); + + if (highlight === -1) throw new PlayerNotFoundException(); + + top = highlight - (highlight % PAGE_SIZE); + bottom = top + PAGE_SIZE; + break; + } + + case LeaderboardQuery.POSITION: { + const position = (input as number) - 1; + highlight = position; + top = position - (position % PAGE_SIZE); + bottom = top + PAGE_SIZE; + break; + } + } + + const page = leaderboard.slice(top, bottom); + + const additionalFieldMetadata = additionalFields.map((k) => + LeaderboardScanner.getLeaderboardField(Player, k, false) + ); + + const extraDisplayMetadata = extraDisplay ? + LeaderboardScanner.getLeaderboardField(Player, extraDisplay, false) : + undefined; + + const additionalStats = await this.getAdditionalStats( + page.map(({ id }) => id), + [ + ...additionalFields.filter((k) => k !== field), + ...(extraDisplay ? [extraDisplay] : []), + ] + ); + + const data = page.map((doc, index) => { + const stats = additionalStats[index]; + + if (extraDisplay) { + const extraDisplayValue = stats[extraDisplay] ?? extraDisplayMetadata?.default; + stats.name = extraDisplayValue ? `${extraDisplayValue}§r ${stats.name}` : stats.name; + } + + const field = formatter ? formatter(doc.score) : doc.score; + + const additionalValues = additionalFields.map((key, index) => { + const value = stats[key] ?? additionalFieldMetadata[index].default; + + if (additionalFieldMetadata[index].formatter) + return additionalFieldMetadata[index].formatter?.(value); + + return value; + }); + + const fields = []; + + if (!hidden) fields.push(field); + fields.push(...additionalValues); + + return { + id: doc.id, + fields, + name: stats.name, + position: doc.index + 1, + highlight: doc.index === highlight, + }; + }); + + const fields = []; + if (!hidden) fields.push(fieldName); + fields.push(...additionalFieldMetadata.map(({ fieldName }) => fieldName)); + + return { + name: `${name} - ${guild.nameFormatted ?? guild.name}`, + fields, + data, + page: top / PAGE_SIZE, + }; + } + + public async getGuildScopedLeaderboardRankings( + guildInput: string, + fields: string[], + id: string + ) { + const guild = await this.getGuild(guildInput); + const memberIds = guild.members.map(({ uuid }) => uuid); + const targetIndex = memberIds.indexOf(id); + + if (targetIndex === -1) return []; + + const pipeline = this.redis.pipeline(); + const leaderboardFields = fields.map((field) => { + const metadata = LeaderboardScanner.getLeaderboardField(Player, field); + const key = `player.${field}`; + + pipeline.call("ZMSCORE", key, ...memberIds); + + return metadata; + }); + + const responses = await pipeline.exec(); + + if (!responses) return []; + + const rankings = []; + + for (const [index, response] of responses.entries()) { + const scores = response[1] as (string | null)[] | null; + const targetScore = scores?.[targetIndex]; + + if (!scores || !targetScore) continue; + + const metadata = leaderboardFields[index]; + const targetValue = Number(targetScore); + const sortedScores = scores + .map((score, scoreIndex) => score === null ? + null : + { + id: memberIds[scoreIndex], + score: Number(score), + }) + .filter((score): score is { id: string; score: number } => score !== null) + .sort((a, b) => { + if (a.score !== b.score) + return metadata.sort === "ASC" ? a.score - b.score : b.score - a.score; + + return metadata.sort === "ASC" ? + a.id.localeCompare(b.id) : + b.id.localeCompare(a.id); + }); + + const rank = sortedScores.findIndex((score) => score.id === id) + 1; + + if (rank < 1) continue; + + const formattedValue = metadata.formatter ? + metadata.formatter(targetValue) : + targetValue; + + rankings.push({ + field: fields[index], + rank, + value: formattedValue, + name: metadata.name, + }); + } + + return rankings; + } + + protected async searchLeaderboardInput(input: string, field: string): Promise { + input = await this.getPlayerId(input); + + const ranking = await this.getLeaderboardRankings(Player, [field], input); if (!ranking || !ranking[0] || !ranking[0].rank) throw new PlayerNotFoundException(); - - return ranking[0].rank; - } - - protected async getAdditionalStats( - ids: string[], - fields: string[] - ): Promise { - const selector = fields.reduce((acc, key) => { - acc[key] = true; - return acc; - }, {} as Record); - - selector.displayName = true; - - return await Promise.all( - ids.map(async (id) => { - const player = await this.playerModel - .findOne() - .where("uuid") - .equals(id) - .select(selector) - .lean() - .exec(); - - const additionalStats = flatten(player) as LeaderboardAdditionalStats; - additionalStats.name = additionalStats.displayName; - - return additionalStats; - }) - ); - } -} + + return ranking[0].rank; + } + + protected async getAdditionalStats( + ids: string[], + fields: string[] + ): Promise { + const selector = fields.reduce((acc, key) => { + acc[key] = true; + return acc; + }, {} as Record); + + selector.displayName = true; + selector.uuid = true; + + const players = await this.playerModel + .find() + .where("uuid") + .in(ids) + .select(selector) + .lean() + .exec(); + + const statsById = new Map( + players.map((player) => { + const additionalStats = flatten(player) as LeaderboardAdditionalStats & { + uuid: string; + }; + + additionalStats.name = additionalStats.displayName; + + return [additionalStats.uuid, additionalStats] as const; + }) + ); + + return ids.map((id) => statsById.get(id)!); + } + + private async getGuild(input: string) { + const isGuildId = GUILD_ID_REGEX.test(input); + + const guild = await this.guildModel + .findOne() + .where(isGuildId ? "id" : "nameToLower") + .equals(isGuildId ? input : input.toLowerCase()) + .select({ "id": true, "members.uuid": true, "name": true, "nameFormatted": true }) + .lean() + .exec(); + + if (guild) return guild; + + const fetchedGuild = await this.hypixelService.getGuild( + input, + isGuildId ? "id" : "name" + ); + + if (!fetchedGuild) throw new GuildNotFoundException(); + + const memberIds = fetchedGuild.members.map(({ uuid }) => uuid); + + await Promise.all([ + this.playerModel + .updateMany({ uuid: { $in: memberIds } }, { guildId: fetchedGuild.id }) + .lean() + .exec(), + this.guildModel + .replaceOne({ id: fetchedGuild.id }, serialize(Guild, flatten(fetchedGuild)), { + upsert: true, + }) + .lean() + .exec(), + this.addGuildAutocomplete(fetchedGuild.name), + ]); + + return fetchedGuild; + } + + private async addGuildAutocomplete(name: string) { + if (name.length < 3 || name.length > 32) return; + + await this.redis + .call("FT.SUGADD", "guild:autocomplete", name, "1", "INCR") + .catch(() => undefined); + } + + private async getPlayerId(input: string) { + if (input.length > 16) return input; + + const player = await this.playerService.get(input, CacheLevel.CACHE_ONLY, { + uuid: true, + }); + + if (!player) throw new PlayerNotFoundException(); + + return player.uuid; + } +} diff --git a/apps/api/src/player/player.module.ts b/apps/api/src/player/player.module.ts index 899fdaa34..023ae237f 100644 --- a/apps/api/src/player/player.module.ts +++ b/apps/api/src/player/player.module.ts @@ -6,9 +6,9 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ +import { Guild, Player } from "@statsify/schemas"; import { HypixelModule } from "#hypixel"; import { Module } from "@nestjs/common"; -import { Player } from "@statsify/schemas"; import { PlayerController } from "./player.controller.js"; import { PlayerLeaderboardService } from "./leaderboards/player-leaderboard.service.js"; import { PlayerLeaderboardsController } from "./leaderboards/player-leaderboard.controller.js"; @@ -20,7 +20,7 @@ import { TypegooseModule } from "@m8a/nestjs-typegoose"; @Module({ imports: [ HypixelModule, - TypegooseModule.forFeature([Player]), + TypegooseModule.forFeature([Guild, Player]), ], controllers: [PlayerController, PlayerLeaderboardsController, PlayerSearchController], providers: [PlayerService, PlayerLeaderboardService, PlayerSearchService], diff --git a/apps/api/src/player/search/player-search.controller.ts b/apps/api/src/player/search/player-search.controller.ts index f9edd4cb1..ccbc051b8 100644 --- a/apps/api/src/player/search/player-search.controller.ts +++ b/apps/api/src/player/search/player-search.controller.ts @@ -22,7 +22,7 @@ export class PlayerSearchController { @ApiOkResponse({ type: GetPlayerSearchResponse }) @Auth({ weight: 0 }) @Get() - public async getPlayers(@Query() { query }: PlayerSearchDto) { + public async getPlayers(@Query() { query = "" }: PlayerSearchDto) { const players = await this.playerSearchService.get(query); return { diff --git a/apps/api/src/player/search/player-search.service.ts b/apps/api/src/player/search/player-search.service.ts index 3a99735e2..a3da3a426 100644 --- a/apps/api/src/player/search/player-search.service.ts +++ b/apps/api/src/player/search/player-search.service.ts @@ -6,13 +6,17 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ +import { InjectModel } from "@m8a/nestjs-typegoose"; import { InjectRedis } from "#redis"; import { Injectable } from "@nestjs/common"; import { Logger } from "@statsify/logger"; +import { Player } from "@statsify/schemas"; import { Redis } from "ioredis"; +import type { ReturnModelType } from "@typegoose/typegoose"; const REDI_SEARCH_NOT_INSTALLED = "This error was most likely caused because RediSearch is not installed."; +const PLAYER_AUTOCOMPLETE_KEY = "player:autocomplete"; export interface RedisPlayer { username: string; @@ -23,47 +27,72 @@ export interface RedisPlayer { export class PlayerSearchService { private logger = new Logger("PlayerSearchService"); - public constructor(@InjectRedis() private readonly redis: Redis) {} + public constructor( + @InjectRedis() private readonly redis: Redis, + @InjectModel(Player) private readonly playerModel: ReturnModelType + ) {} - public get(query: string): Promise { - try { - return this.redis.call( + public async get(query: string): Promise { + query = query.trim().toLowerCase(); + + if (!query) return this.getCachedPlayers(); + + const searchOptions = query.length >= 3 ? ["FUZZY", "MAX", "25"] : ["MAX", "25"]; + + const redisResults = await ( + this.redis.call( "FT.SUGGET", - "player:autocomplete", + PLAYER_AUTOCOMPLETE_KEY, query, - "FUZZY", - "MAX", - "25" - ) as Promise; - } catch (e) { - this.logger.error(e); - this.logger.error(REDI_SEARCH_NOT_INSTALLED); - - return Promise.resolve([]); - } + ...searchOptions + ) as Promise + ).catch((e) => { + this.handleRedisError(e); + + return []; + }); + + if (redisResults.length) return redisResults; + + return this.getCachedPlayers(query); } public async add(player: RedisPlayer) { if (player.username.length < 3 || player.username.length > 16) return; - try { - await this.redis.call( - "FT.SUGADD", - "player:autocomplete", - player.username, - "1", - "INCR" - ); - } catch (e) { - this.logger.error(e); - this.logger.error(REDI_SEARCH_NOT_INSTALLED); - } + await this.redis + .call("FT.SUGADD", PLAYER_AUTOCOMPLETE_KEY, player.username, "1", "INCR") + .catch((e) => this.handleRedisError(e)); } public delete(name: string) { - return this.redis.call("FT.SUGDEL", "player:autocomplete", name).catch((e) => { - this.logger.error(e); - this.logger.error(REDI_SEARCH_NOT_INSTALLED); - }); + return this.redis + .call("FT.SUGDEL", PLAYER_AUTOCOMPLETE_KEY, name) + .catch((e) => this.handleRedisError(e)); + } + + private async getCachedPlayers(query?: string) { + const filter = query ? + { usernameToLower: { $regex: `^${this.escapeRegex(query)}` } } : + {}; + + const players = await this.playerModel + .find(filter) + .select({ username: true, _id: false }) + .sort({ "stats.general.networkExp": -1 }) + .limit(25) + .lean() + .exec(); + + return players.map(({ username }) => username); + } + + private escapeRegex(input: string) { + return input.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); + } + + private handleRedisError(error: unknown) { + this.logger.error(error); + this.logger.error(REDI_SEARCH_NOT_INSTALLED); } } 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 7c32580d2..24d51f9ef 100644 --- a/apps/discord-bot/src/commands/leaderboards/base.leaderboard-command.tsx +++ b/apps/discord-bot/src/commands/leaderboards/base.leaderboard-command.tsx @@ -33,7 +33,10 @@ import { getTheme } from "#themes"; import { render } from "@statsify/rendering"; import type { Image } from "skia-canvas"; -type BaseLeaderboardProps = Omit; +type BaseLeaderboardProps = Omit< + LeaderboardProfileProps, + "background" | "fields" | "logo" | "name" | "data" +>; interface LeaderboardParams { input: string | number; @@ -50,7 +53,7 @@ type GetLeaderboardDataIcon = (id: string) => Promise; export interface CreateLeaderboardOptions { context: CommandContext; - background: Image; + background: Image | Promise; type: LeaderboardType; getLeaderboard: GetLeaderboard; field: string; @@ -70,13 +73,11 @@ export class BaseLeaderboardCommand { const user = context.getUser(); const t = context.t(); const cache = new Map(); - - const logo = await getLogo(user); + const backgroundPromise = Promise.resolve(background); + const logoPromise = getLogo(user); const props: BaseLeaderboardProps = { t, - background, - logo, user, type, }; @@ -112,12 +113,14 @@ export class BaseLeaderboardCommand { field, params, props, + backgroundPromise, + logoPromise, getLeaderboardDataIcon ); if (interaction.getUserId() === userId && !message.ephemeral) { up.disable(page === 0); - currentPage = page || currentPage; + currentPage = page ?? currentPage; const row = new ActionRowBuilder([up, down, searchDocument, searchPosition]); context.reply({ @@ -224,6 +227,8 @@ export class BaseLeaderboardCommand { field, { input: currentPage, type: LeaderboardQuery.PAGE }, props, + backgroundPromise, + logoPromise, getLeaderboardDataIcon ); @@ -241,7 +246,7 @@ export class BaseLeaderboardCommand { cache.clear(); }, 300_000); - currentPage = page || currentPage; + currentPage = page ?? currentPage; return { ...message, components: [row] }; } @@ -254,6 +259,8 @@ export class BaseLeaderboardCommand { field: string, params: LeaderboardParams, props: BaseLeaderboardProps, + backgroundPromise: Promise, + logoPromise: Promise, getLeaderboardDataIcon?: GetLeaderboardDataIcon ): Promise<[message: IMessage, page: number | null]> { if (params.type === LeaderboardQuery.PAGE && cache.has(params.input as number)) { @@ -268,10 +275,12 @@ export class BaseLeaderboardCommand { field, params, props, + backgroundPromise, + logoPromise, getLeaderboardDataIcon ); - if (params.type === LeaderboardQuery.PAGE && page) cache.set(page, message); + if (params.type === LeaderboardQuery.PAGE && page !== null) cache.set(page, message); return [message, page]; } @@ -283,9 +292,15 @@ export class BaseLeaderboardCommand { field: string, params: LeaderboardParams, props: BaseLeaderboardProps, + backgroundPromise: Promise, + logoPromise: Promise, getLeaderboardDataIcon?: GetLeaderboardDataIcon ): Promise<[message: IMessage, page: number | null]> { - const leaderboard = await getLeaderboard(field, params.input, params.type); + const [background, logo, leaderboard] = await Promise.all([ + backgroundPromise, + logoPromise, + getLeaderboard(field, params.input, params.type), + ]); if (!leaderboard) { const message = { @@ -317,6 +332,8 @@ export class BaseLeaderboardCommand { const canvas = render( ("leaderboard"); const field = leaderboard.replace(/ /g, "."); - const background = await getBackground("hypixel", "overall"); + const background = getBackground("hypixel", "overall"); return this.createLeaderboard({ context, diff --git a/apps/discord-bot/src/commands/leaderboards/player-leaderboard.argument.ts b/apps/discord-bot/src/commands/leaderboards/player-leaderboard.argument.ts index a6c2e730e..18d868763 100644 --- a/apps/discord-bot/src/commands/leaderboards/player-leaderboard.argument.ts +++ b/apps/discord-bot/src/commands/leaderboards/player-leaderboard.argument.ts @@ -11,15 +11,23 @@ import { APIApplicationCommandOptionChoice, ApplicationCommandOptionType, } from "discord-api-types/v10"; -import { AbstractArgument, CommandContext, LocalizationString } from "@statsify/discord"; +import { + AbstractArgument, + ApiService, + CommandContext, + LocalizationString, +} from "@statsify/discord"; import { ClassMetadata, LeaderboardScanner, METADATA_KEY, PlayerStats, } from "@statsify/schemas"; +import { Container } from "typedi"; import { removeFormatting } from "@statsify/util"; +const apiClient = Container.get(ApiService); + const entries = Object.entries( Reflect.getMetadata(METADATA_KEY, PlayerStats.prototype) as ClassMetadata ); @@ -70,3 +78,48 @@ export class PlayerLeaderboardArgument extends AbstractArgument { .slice(0, 25); } } + +export class PlayerLeaderboardGuildArgument extends AbstractArgument { + public name = "guild"; + public description: LocalizationString; + public type = ApplicationCommandOptionType.String; + public required = false; + public autocomplete = true; + + public constructor() { + super(); + this.description = (t) => t("arguments.guild-filter"); + } + + public async autocompleteHandler( + context: CommandContext + ): Promise { + const query = context.option(this.name, "").toLowerCase(); + + const searched = { name: query, value: query }; + + if (!query) { + const guilds = await apiClient.getGuildAutocomplete(query); + + return guilds.map((guild) => ({ name: guild, value: guild })); + } + + if (query.length > 32) return [searched]; + + const guilds = await apiClient.getGuildAutocomplete(query); + + let results = guilds.map((guild) => ({ name: guild, value: guild })); + + if (query && (!guilds.length || !guilds.some((guild) => guild.toLowerCase() === query))) { + results = results.slice(0, 24); + results.push(searched); + } + + return results; + } +} + +export const createPlayerLeaderboardArguments = (prefix: keyof PlayerStats) => [ + new PlayerLeaderboardArgument(prefix), + new PlayerLeaderboardGuildArgument(), +]; diff --git a/apps/discord-bot/src/commands/leaderboards/player-leaderboard.command.ts b/apps/discord-bot/src/commands/leaderboards/player-leaderboard.command.ts index 1a9159e80..2ff71557f 100644 --- a/apps/discord-bot/src/commands/leaderboards/player-leaderboard.command.ts +++ b/apps/discord-bot/src/commands/leaderboards/player-leaderboard.command.ts @@ -31,6 +31,7 @@ import { TNT_GAMES_MODES, TURBO_KART_RACERS_MODES, UHC_MODES, + UserTier, VAMPIREZ_MODES, WALLS_MODES, WARLORDS_MODES, @@ -40,6 +41,7 @@ import { ApiService, Command, CommandContext, + ErrorMessage, SubCommand, } from "@statsify/discord"; import { BaseLeaderboardCommand } from "./base.leaderboard-command.js"; @@ -48,7 +50,7 @@ import { GamesWithBackgrounds, mapBackground } from "#constants"; import { GuildLeaderboardArgument } from "./guild-leaderboard.argument.js"; import { GuildLeaderboardSubCommand } from "./guild-leaderboard.subcommand.js"; import { - PlayerLeaderboardArgument, + createPlayerLeaderboardArguments, } from "./player-leaderboard.argument.js"; import { getBackground } from "@statsify/assets"; @@ -63,7 +65,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-arcade"), - args: [new PlayerLeaderboardArgument("arcade")], + args: createPlayerLeaderboardArguments("arcade"), }) public arcade(context: CommandContext) { return this.run(context, "arcade", ARCADE_MODES); @@ -71,7 +73,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-arenabrawl"), - args: [new PlayerLeaderboardArgument("arenabrawl")], + args: createPlayerLeaderboardArguments("arenabrawl"), group: "classic", }) public arenabrawl(context: CommandContext) { @@ -80,7 +82,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-bedwars"), - args: [new PlayerLeaderboardArgument("bedwars")], + args: createPlayerLeaderboardArguments("bedwars"), }) public bedwars(context: CommandContext) { return this.run(context, "bedwars", BEDWARS_MODES); @@ -88,7 +90,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-blitzsg"), - args: [new PlayerLeaderboardArgument("blitzsg")], + args: createPlayerLeaderboardArguments("blitzsg"), }) public blitzsg(context: CommandContext) { return this.run(context, "blitzsg", BLITZSG_MODES); @@ -96,7 +98,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-buildbattle"), - args: [new PlayerLeaderboardArgument("buildbattle")], + args: createPlayerLeaderboardArguments("buildbattle"), }) public buildbattle(context: CommandContext) { return this.run(context, "buildbattle", BUILD_BATTLE_MODES); @@ -104,7 +106,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-challenges"), - args: [new PlayerLeaderboardArgument("challenges")], + args: createPlayerLeaderboardArguments("challenges"), }) public challenges(context: CommandContext) { return this.run(context, "challenges", CHALLENGE_MODES); @@ -112,7 +114,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-copsandcrims"), - args: [new PlayerLeaderboardArgument("copsandcrims")], + args: createPlayerLeaderboardArguments("copsandcrims"), }) public copsandcrims(context: CommandContext) { return this.run(context, "copsandcrims", COPS_AND_CRIMS_MODES); @@ -120,7 +122,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-duels"), - args: [new PlayerLeaderboardArgument("duels")], + args: createPlayerLeaderboardArguments("duels"), }) public duels(context: CommandContext) { return this.run(context, "duels", DUELS_MODES); @@ -128,7 +130,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-general"), - args: [new PlayerLeaderboardArgument("general")], + args: createPlayerLeaderboardArguments("general"), }) public general(context: CommandContext) { return this.run(context, "general", GENERAL_MODES); @@ -136,7 +138,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-megawalls"), - args: [new PlayerLeaderboardArgument("megawalls")], + args: createPlayerLeaderboardArguments("megawalls"), }) public megawalls(context: CommandContext) { return this.run(context, "megawalls", MEGAWALLS_MODES); @@ -144,7 +146,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-murdermystery"), - args: [new PlayerLeaderboardArgument("murdermystery")], + args: createPlayerLeaderboardArguments("murdermystery"), }) public murdermystery(context: CommandContext) { return this.run(context, "murdermystery", MURDER_MYSTERY_MODES); @@ -152,7 +154,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-paintball"), - args: [new PlayerLeaderboardArgument("paintball")], + args: createPlayerLeaderboardArguments("paintball"), group: "classic", }) public paintball(context: CommandContext) { @@ -161,7 +163,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-parkour"), - args: [new PlayerLeaderboardArgument("parkour")], + args: createPlayerLeaderboardArguments("parkour"), }) public parkour(context: CommandContext) { return this.run(context, "parkour", PARKOUR_MODES); @@ -169,7 +171,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-pit"), - args: [new PlayerLeaderboardArgument("pit")], + args: createPlayerLeaderboardArguments("pit"), }) public pit(context: CommandContext) { return this.run(context, "pit", PIT_MODES); @@ -177,7 +179,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-quake"), - args: [new PlayerLeaderboardArgument("quake")], + args: createPlayerLeaderboardArguments("quake"), group: "classic", }) public quake(context: CommandContext) { @@ -186,7 +188,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-quests"), - args: [new PlayerLeaderboardArgument("quests")], + args: createPlayerLeaderboardArguments("quests"), }) public quests(context: CommandContext) { return this.run(context, "quests", QUEST_MODES); @@ -194,7 +196,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-skywars"), - args: [new PlayerLeaderboardArgument("skywars")], + args: createPlayerLeaderboardArguments("skywars"), }) public skywars(context: CommandContext) { return this.run(context, "skywars", SKYWARS_MODES); @@ -202,7 +204,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-smashheroes"), - args: [new PlayerLeaderboardArgument("smashheroes")], + args: createPlayerLeaderboardArguments("smashheroes"), }) public smashheroes(context: CommandContext) { return this.run(context, "smashheroes", SMASH_HEROES_MODES); @@ -210,7 +212,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-speeduhc"), - args: [new PlayerLeaderboardArgument("speeduhc")], + args: createPlayerLeaderboardArguments("speeduhc"), }) public speeduhc(context: CommandContext) { return this.run(context, "speeduhc", SPEED_UHC_MODES); @@ -218,7 +220,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-tntgames"), - args: [new PlayerLeaderboardArgument("tntgames")], + args: createPlayerLeaderboardArguments("tntgames"), }) public tntgames(context: CommandContext) { return this.run(context, "tntgames", TNT_GAMES_MODES); @@ -226,7 +228,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-turbokartracers"), - args: [new PlayerLeaderboardArgument("turbokartracers")], + args: createPlayerLeaderboardArguments("turbokartracers"), group: "classic", }) public turbokartracers(context: CommandContext) { @@ -235,7 +237,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-uhc"), - args: [new PlayerLeaderboardArgument("uhc")], + args: createPlayerLeaderboardArguments("uhc"), }) public uhc(context: CommandContext) { return this.run(context, "uhc", UHC_MODES); @@ -243,7 +245,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-vampirez"), - args: [new PlayerLeaderboardArgument("vampirez")], + args: createPlayerLeaderboardArguments("vampirez"), group: "classic", }) public vampirez(context: CommandContext) { @@ -252,7 +254,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-walls"), - args: [new PlayerLeaderboardArgument("walls")], + args: createPlayerLeaderboardArguments("walls"), group: "classic", }) public walls(context: CommandContext) { @@ -261,7 +263,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-warlords"), - args: [new PlayerLeaderboardArgument("warlords")], + args: createPlayerLeaderboardArguments("warlords"), }) public warlords(context: CommandContext) { return this.run(context, "warlords", WARLORDS_MODES); @@ -269,7 +271,7 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { @SubCommand({ description: (t) => t("commands.leaderboard-woolgames"), - args: [new PlayerLeaderboardArgument("woolgames")], + args: createPlayerLeaderboardArguments("woolgames"), }) public woolgames(context: CommandContext) { return this.run(context, "woolgames", WOOLGAMES_MODES); @@ -289,14 +291,21 @@ export class PlayerLeaderboardCommand extends BaseLeaderboardCommand { modes: GameModes ) { const leaderboard = context.option("leaderboard"); + const guild = context.option("guild", null); + + if (guild && (context.getUser()?.tier ?? UserTier.NONE) < UserTier.DIAMOND) { + throw new ErrorMessage("errors.diamondOnly"); + } const field = `stats.${prefix}.${leaderboard.replaceAll(" ", ".")}`; - const background = await getBackground( + const background = getBackground( ...mapBackground(modes, modes.getApiModes()[0]) ); - const getLeaderboard = this.apiService.getPlayerLeaderboard.bind(this.apiService); + const getLeaderboard = guild ? + this.apiService.getGuildScopedPlayerLeaderboard.bind(this.apiService, guild) : + this.apiService.getPlayerLeaderboard.bind(this.apiService); return this.createLeaderboard({ context, diff --git a/apps/discord-bot/src/commands/rankings/rankings.command.tsx b/apps/discord-bot/src/commands/rankings/rankings.command.tsx index 2aa88ac6f..27b321812 100644 --- a/apps/discord-bot/src/commands/rankings/rankings.command.tsx +++ b/apps/discord-bot/src/commands/rankings/rankings.command.tsx @@ -53,6 +53,7 @@ import { } from "@statsify/discord"; import { ButtonStyle } from "discord-api-types/v10"; import { type GamesWithBackgrounds, mapBackground } from "#constants"; +import { PlayerLeaderboardGuildArgument } from "../leaderboards/player-leaderboard.argument.js"; import { RankingsProfile } from "./rankings.profile.js"; import { arrayGroup } from "@statsify/util"; import { games } from "./games.js"; @@ -66,7 +67,7 @@ const choices = games.map((g) => [g.name, g.key] as Choice); choices.unshift(["All", "all"]); const options: Partial = { - args: [PlayerArgument], + args: [PlayerArgument, PlayerLeaderboardGuildArgument], tier: UserTier.IRON, preview: "rankings.png", }; @@ -313,6 +314,11 @@ export class RankingsCommand { const t = context.t(); const player = await this.apiService.getPlayer(context.option("player"), user); + const guild = context.option("guild", null); + + if (guild && (user?.tier ?? UserTier.NONE) < UserTier.DIAMOND) { + throw new ErrorMessage("errors.diamondOnly"); + } const isGameNotAll = game !== "all"; @@ -320,7 +326,11 @@ export class RankingsCommand { fields.filter((f) => f.startsWith(`stats.${game}`)) : fields; - const rankings = await this.apiService.getPlayerRankings(filteredFields, player.uuid); + const rankings = await this.apiService.getPlayerRankings( + filteredFields, + player.uuid, + guild ?? undefined + ); if (!rankings.length) throw new ErrorMessage( diff --git a/locales/bg/default.json b/locales/bg/default.json index 35d547692..7160cbd2f 100644 --- a/locales/bg/default.json +++ b/locales/bg/default.json @@ -5,6 +5,7 @@ "gtbhelper": "The current hint", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "A guild name or guild member", + "guild-filter": "A guild name or guild ID", "guild-type": "The type of guild search to perform", "mojang-player": "A Minecraft username or UUID", "number": "A number", diff --git a/locales/cs/default.json b/locales/cs/default.json index f0477b0ec..40355a2c2 100644 --- a/locales/cs/default.json +++ b/locales/cs/default.json @@ -5,6 +5,7 @@ "gtbhelper": "Aktuální nápověda", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "Guild název nebo její člen", + "guild-filter": "A guild name or guild ID", "guild-type": "Způsob hledání", "mojang-player": "Minecraft jméno nebo UUID", "number": "Číslo", diff --git a/locales/da/default.json b/locales/da/default.json index 35d547692..7160cbd2f 100644 --- a/locales/da/default.json +++ b/locales/da/default.json @@ -5,6 +5,7 @@ "gtbhelper": "The current hint", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "A guild name or guild member", + "guild-filter": "A guild name or guild ID", "guild-type": "The type of guild search to perform", "mojang-player": "A Minecraft username or UUID", "number": "A number", diff --git a/locales/de/default.json b/locales/de/default.json index 4f86aae77..9627c3510 100644 --- a/locales/de/default.json +++ b/locales/de/default.json @@ -5,6 +5,7 @@ "gtbhelper": "Der aktuelle Hinweis", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "Ein Gildenname oder Gildenmitglied", + "guild-filter": "A guild name or guild ID", "guild-type": "Die Art der zu auszuführenden Gildensuche", "mojang-player": "Ein Minecraft Benutzername oder UUID", "number": "Eine Nummer", diff --git a/locales/el/default.json b/locales/el/default.json index b28c93eac..064c69c01 100644 --- a/locales/el/default.json +++ b/locales/el/default.json @@ -5,6 +5,7 @@ "gtbhelper": "Το τρέχον στοιχείο", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "Ένα guild όνομα ή guild μέλος", + "guild-filter": "A guild name or guild ID", "guild-type": "Ο τύπος αναζήτησης guild για εκτέλεση", "mojang-player": "Ένα όνομα χρήστη ή UUID του Minecraft", "number": "Ένας αριθμός", diff --git a/locales/en-US/default.json b/locales/en-US/default.json index b66f3e884..7a17f3661 100644 --- a/locales/en-US/default.json +++ b/locales/en-US/default.json @@ -5,6 +5,7 @@ "gtbhelper": "The current hint", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "A guild name or guild member", + "guild-filter": "A guild name or guild ID", "guild-type": "The type of guild search to perform", "mojang-player": "A Minecraft username or UUID", "number": "A number", diff --git a/locales/es-ES/default.json b/locales/es-ES/default.json index de252fc94..609714470 100644 --- a/locales/es-ES/default.json +++ b/locales/es-ES/default.json @@ -5,6 +5,7 @@ "gtbhelper": "La pista actual", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "Un nombre de gremio o un miembro", + "guild-filter": "A guild name or guild ID", "guild-type": "El tipo de búsqueda de gremio a realizar", "mojang-player": "Nombre de usuario en Minecraft o UUID", "number": "Un número", diff --git a/locales/fi/default.json b/locales/fi/default.json index 4a05c165d..7c981bd38 100644 --- a/locales/fi/default.json +++ b/locales/fi/default.json @@ -5,6 +5,7 @@ "gtbhelper": "Nykyinen vihje", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "Guildin nimi tai guildin jäsen", + "guild-filter": "A guild name or guild ID", "guild-type": "Suoritettavan guild-haun tyyppi", "mojang-player": "Minecraft-käyttäjätunnus tai UUID", "number": "Numero", diff --git a/locales/fr/default.json b/locales/fr/default.json index cad5334ba..95c51122d 100644 --- a/locales/fr/default.json +++ b/locales/fr/default.json @@ -5,6 +5,7 @@ "gtbhelper": "L'indice actuel", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "Un nom ou membre de guilde", + "guild-filter": "A guild name or guild ID", "guild-type": "Le type de recherche de guilde à effectuer", "mojang-player": "Un nom d'utilisateur ou UUID Minecraft", "number": "Un nombre", diff --git a/locales/hi/default.json b/locales/hi/default.json index 35d547692..7160cbd2f 100644 --- a/locales/hi/default.json +++ b/locales/hi/default.json @@ -5,6 +5,7 @@ "gtbhelper": "The current hint", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "A guild name or guild member", + "guild-filter": "A guild name or guild ID", "guild-type": "The type of guild search to perform", "mojang-player": "A Minecraft username or UUID", "number": "A number", diff --git a/locales/hr/default.json b/locales/hr/default.json index 35d547692..7160cbd2f 100644 --- a/locales/hr/default.json +++ b/locales/hr/default.json @@ -5,6 +5,7 @@ "gtbhelper": "The current hint", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "A guild name or guild member", + "guild-filter": "A guild name or guild ID", "guild-type": "The type of guild search to perform", "mojang-player": "A Minecraft username or UUID", "number": "A number", diff --git a/locales/hu/default.json b/locales/hu/default.json index 1ad81629c..36a268e1b 100644 --- a/locales/hu/default.json +++ b/locales/hu/default.json @@ -5,6 +5,7 @@ "gtbhelper": "A jelenlegi segítség", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "Egy guild név vagy guild tag", + "guild-filter": "A guild name or guild ID", "guild-type": "A guild keresés típusa", "mojang-player": "Egy Minecraft felhasználónév vagy UUID", "number": "Egy szám", diff --git a/locales/it/default.json b/locales/it/default.json index 88ee58e5e..826036a7a 100644 --- a/locales/it/default.json +++ b/locales/it/default.json @@ -5,6 +5,7 @@ "gtbhelper": "Suggerimento attuale", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "Un nome di una gilda o un membro di una gilda", + "guild-filter": "A guild name or guild ID", "guild-type": "Il tipo di ricerca di gilda da eseguire", "mojang-player": "Un nome utente di Minecraft o UUID", "number": "Numero", diff --git a/locales/ja/default.json b/locales/ja/default.json index b618136da..5b62af74d 100644 --- a/locales/ja/default.json +++ b/locales/ja/default.json @@ -5,6 +5,7 @@ "gtbhelper": "The current hint", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "A guild name or guild member", + "guild-filter": "A guild name or guild ID", "guild-type": "The type of guild search to perform", "mojang-player": "A Minecraft username or UUID", "number": "A number", diff --git a/locales/ko/default.json b/locales/ko/default.json index f9a7e4125..dd5289d8b 100644 --- a/locales/ko/default.json +++ b/locales/ko/default.json @@ -5,6 +5,7 @@ "gtbhelper": "현재 주어진 힌트", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "길드 이름 또는 길드 구성원", + "guild-filter": "A guild name or guild ID", "guild-type": "검색하고자 하는 길드 유형", "mojang-player": "Minecraft 사용자 이름 또는 UUID", "number": "숫자", diff --git a/locales/lt/default.json b/locales/lt/default.json index 35d547692..7160cbd2f 100644 --- a/locales/lt/default.json +++ b/locales/lt/default.json @@ -5,6 +5,7 @@ "gtbhelper": "The current hint", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "A guild name or guild member", + "guild-filter": "A guild name or guild ID", "guild-type": "The type of guild search to perform", "mojang-player": "A Minecraft username or UUID", "number": "A number", diff --git a/locales/nl/default.json b/locales/nl/default.json index 5be0a5a96..5b8b8a216 100644 --- a/locales/nl/default.json +++ b/locales/nl/default.json @@ -5,6 +5,7 @@ "gtbhelper": "De huidige tip", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "Een guild naam of guild lid", + "guild-filter": "A guild name or guild ID", "guild-type": "Het type guild zoekopdracht om uit te voeren", "mojang-player": "Een Minecraft gebruikersnaam of UUID", "number": "Een getal", diff --git a/locales/no/default.json b/locales/no/default.json index 54d18d9dd..128581014 100644 --- a/locales/no/default.json +++ b/locales/no/default.json @@ -5,6 +5,7 @@ "gtbhelper": "Det gjeldende hintet", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "Et guild-navn eller guild-medlem", + "guild-filter": "A guild name or guild ID", "guild-type": "Typen guild-søk som skal utføres", "mojang-player": "Et Minecraft-brukernavn eller UUID", "number": "Et tall", diff --git a/locales/pl/default.json b/locales/pl/default.json index 653b3b18e..de24d9474 100644 --- a/locales/pl/default.json +++ b/locales/pl/default.json @@ -5,6 +5,7 @@ "gtbhelper": "Bieżąca wskazówka", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "Nazwa gildii lub nazwa członka gildii", + "guild-filter": "A guild name or guild ID", "guild-type": "Typ wyszukiwania gildii do wykonania", "mojang-player": "Nazwa użytkownika Minecraft lub UUID", "number": "Liczba", diff --git a/locales/pt-BR/default.json b/locales/pt-BR/default.json index f1dad20f8..a4ab90977 100644 --- a/locales/pt-BR/default.json +++ b/locales/pt-BR/default.json @@ -5,6 +5,7 @@ "gtbhelper": "A dica atual", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "O nome de uma guild ou um membro da guild", + "guild-filter": "A guild name or guild ID", "guild-type": "A categoria de pesquisa da guild para realizar", "mojang-player": "Um nome de usuário ou UUID", "number": "Um número", diff --git a/locales/ro/default.json b/locales/ro/default.json index feef58f5d..4b4f52e45 100644 --- a/locales/ro/default.json +++ b/locales/ro/default.json @@ -5,6 +5,7 @@ "gtbhelper": "Sugestie actuală", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "Numele Guild-ului sau membru al guild-ului", + "guild-filter": "A guild name or guild ID", "guild-type": "Tipul de căutare de guild de efectuat", "mojang-player": "Numele jucătorului de Minecraft sau UUID", "number": "Un număr", diff --git a/locales/ru/default.json b/locales/ru/default.json index 0adce3f58..b550e23ef 100644 --- a/locales/ru/default.json +++ b/locales/ru/default.json @@ -5,6 +5,7 @@ "gtbhelper": "Текущая подсказка", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "Название гильдии или участника гильдии", + "guild-filter": "A guild name or guild ID", "guild-type": "Тип поиска гильдии для выполнения", "mojang-player": "Имя пользователя Minecraft или UUID", "number": "Номер", diff --git a/locales/sv-SE/default.json b/locales/sv-SE/default.json index 8ae5d6d30..72b449158 100644 --- a/locales/sv-SE/default.json +++ b/locales/sv-SE/default.json @@ -5,6 +5,7 @@ "gtbhelper": "Den nuvarande ledtråden", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "Ett guild namn eller en guild medlem", + "guild-filter": "A guild name or guild ID", "guild-type": "Typen av guild sökning att utföra", "mojang-player": "Ett Minecraft användarnamn eller UUID", "number": "En siffra", diff --git a/locales/th/default.json b/locales/th/default.json index 08e3db2cf..c36f65f32 100644 --- a/locales/th/default.json +++ b/locales/th/default.json @@ -5,6 +5,7 @@ "gtbhelper": "คำใบ้ปัจจุบัน", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "ชื่อกิลด์หรือสมาชิกกิลด์", + "guild-filter": "A guild name or guild ID", "guild-type": "ประเภทของการค้นหากิลด์ที่จะดำเนินการ", "mojang-player": "ชื่อผู้ใช้ Minecraft หรือ UUID", "number": "หมายเลข", diff --git a/locales/tr/default.json b/locales/tr/default.json index 8023b5fb1..1cb8a6569 100644 --- a/locales/tr/default.json +++ b/locales/tr/default.json @@ -5,6 +5,7 @@ "gtbhelper": "Mevcut ipucu", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "Guild ismi veya üyesi", + "guild-filter": "A guild name or guild ID", "guild-type": "Gerçekleştirilecek guild arama tipi", "mojang-player": "Minecraft kullanıcı adı veya UUID'si", "number": "Bir sayı", diff --git a/locales/uk/default.json b/locales/uk/default.json index f74a0bda6..e5ccf31b0 100644 --- a/locales/uk/default.json +++ b/locales/uk/default.json @@ -5,6 +5,7 @@ "gtbhelper": "Поточна підказка", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "Назва гільдії або учасник гільдії", + "guild-filter": "A guild name or guild ID", "guild-type": "Тип пошуку гільдії для виконання", "mojang-player": "Ім'я користувача Minecraft або UUID", "number": "Число", diff --git a/locales/vi/default.json b/locales/vi/default.json index c2eb6467c..3c51df36d 100644 --- a/locales/vi/default.json +++ b/locales/vi/default.json @@ -5,6 +5,7 @@ "gtbhelper": "Gợi ý hiện tại", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "Một tên đoàn hoặc một thành viên đoàn", + "guild-filter": "A guild name or guild ID", "guild-type": "Kiểu đoàn để thực hiện", "mojang-player": "Một tên Minecraft hoặc UUID", "number": "Một con số", diff --git a/locales/zh-CN/default.json b/locales/zh-CN/default.json index 6d137ecc3..91c3ce881 100644 --- a/locales/zh-CN/default.json +++ b/locales/zh-CN/default.json @@ -5,6 +5,7 @@ "gtbhelper": "目前提示", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "一个公会名称或公会成员", + "guild-filter": "A guild name or guild ID", "guild-type": "要执行的公会的搜索类型", "mojang-player": "一个我的世界的用户名或UUID", "number": "一个数字", diff --git a/locales/zh-TW/default.json b/locales/zh-TW/default.json index 6bcca273a..2eedf3a5b 100644 --- a/locales/zh-TW/default.json +++ b/locales/zh-TW/default.json @@ -5,6 +5,7 @@ "gtbhelper": "目前提示", "guild-leaderboard": "$t(arguments.player-leaderboard)", "guild-query": "一個公會名稱或公會成員", + "guild-filter": "A guild name or guild ID", "guild-type": "要搜尋的公會類型", "mojang-player": "一個當個創世神的使用者名稱或是UUID", "number": "一個數字", diff --git a/packages/api-client/src/api.service.ts b/packages/api-client/src/api.service.ts index b2d1d7677..e1c276d64 100644 --- a/packages/api-client/src/api.service.ts +++ b/packages/api-client/src/api.service.ts @@ -18,6 +18,7 @@ import { GetCommandUsageResponse, GetGamecountsResponse, GetGuildResponse, + GetGuildSearchResponse, GetKeyResponse, GetPlayerResponse, GetPlayerSearchResponse, @@ -26,6 +27,7 @@ import { GetStatusResponse, GetUserResponse, GetWatchdogResponse, + PostGuildScopedPlayerLeaderboardResponse, PostLeaderboardRankingsResponse, PostLeaderboardResponse, PutUserBadgeResponse, @@ -41,6 +43,11 @@ interface ExtraData { responseType?: ResponseType; } +interface AutocompleteCacheEntry { + expiresAt: number; + value: Promise; +} + // TODO: Move dtos in api to @statsify/api-client interface UpdateUser { serverMember?: boolean; @@ -50,9 +57,12 @@ interface UpdateUser { } const isProduction = await config("environment") === "prod"; +const AUTOCOMPLETE_CACHE_TTL = 15_000; +const AUTOCOMPLETE_CACHE_LIMIT = 250; export class ApiService { private axios: AxiosInstance; + private autocompleteCache = new Map(); public constructor(private apiRoute: string, private apiKey: string) { this.axios = Axios.create({ @@ -96,23 +106,60 @@ export class ApiService { }); } - public getPlayerRankings(fields: string[], uuid: string) { + public getGuildScopedPlayerLeaderboard( + guild: string, + field: string, + input: string | number, + type: LeaderboardQuery + ): Promise { + return this.request( + "/player/leaderboards/guild", + {}, + "POST", + { + body: { + guild, + field, + [type === LeaderboardQuery.INPUT ? "player" : type]: input, + }, + } + ); + } + + public getPlayerRankings(fields: string[], uuid: string, guild?: string) { return this.request( "/player/leaderboards/rankings", {}, "POST", - { body: { fields, uuid } } + { body: { fields, guild, uuid } } ); } public getPlayerAutocomplete(query: string) { - return this.requestKey( - "/player/search", - "players", - { query } + const normalizedQuery = query.trim().toLowerCase(); + + return this.getCachedAutocomplete("player", normalizedQuery, () => + this.requestKey( + "/player/search", + "players", + normalizedQuery ? { query: normalizedQuery } : {} + ) ); } + public getGuildAutocomplete(query: string) { + const normalizedQuery = query.trim().toLowerCase(); + + const request = () => + this.requestKey( + "/guild/search", + "guilds", + normalizedQuery ? { query: normalizedQuery } : {} + ); + + return this.getCachedAutocomplete("guild", normalizedQuery, request); + } + public getGuild(tag: string, type: GuildQuery) { return this.requestKey("/guild", "guild", { guild: tag, @@ -267,6 +314,34 @@ export class ApiService { return this.request("/commands", { command }, "PATCH"); } + private getCachedAutocomplete( + namespace: string, + query: string, + request: () => Promise + ) { + const normalizedQuery = query.trim().toLowerCase(); + + const key = `${namespace}:${normalizedQuery}`; + const cached = this.autocompleteCache.get(key); + const now = Date.now(); + + if (cached && cached.expiresAt > now) return cached.value; + + const value = request().catch(() => []); + + this.autocompleteCache.set(key, { + value, + expiresAt: now + AUTOCOMPLETE_CACHE_TTL, + }); + + if (this.autocompleteCache.size > AUTOCOMPLETE_CACHE_LIMIT) { + const oldestKey = this.autocompleteCache.keys().next().value; + if (oldestKey) this.autocompleteCache.delete(oldestKey); + } + + return value; + } + private async requestKey( url: string, key: K, diff --git a/packages/api-client/src/responses/get.guild-search.response.ts b/packages/api-client/src/responses/get.guild-search.response.ts new file mode 100644 index 000000000..c04de56b4 --- /dev/null +++ b/packages/api-client/src/responses/get.guild-search.response.ts @@ -0,0 +1,15 @@ +/** + * 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 { ApiProperty } from "@nestjs/swagger"; +import { SuccessResponse } from "./success.response.js"; + +export class GetGuildSearchResponse extends SuccessResponse { + @ApiProperty({ type: [String] }) + public guilds: string[]; +} diff --git a/packages/api-client/src/responses/index.ts b/packages/api-client/src/responses/index.ts index 5457251c2..5e692a37f 100644 --- a/packages/api-client/src/responses/index.ts +++ b/packages/api-client/src/responses/index.ts @@ -10,6 +10,7 @@ export * from "./delete.player.response.js"; export * from "./error.response.js"; export * from "./get.gamecounts.response.js"; export * from "./get.guild.response.js"; +export * from "./get.guild-search.response.js"; export * from "./get.session.response.js"; export * from "./get.key.response.js"; export * from "./get.player.response.js"; diff --git a/packages/api-client/src/responses/post.leaderboard.response.ts b/packages/api-client/src/responses/post.leaderboard.response.ts index 415a227d1..b97f7d7fe 100644 --- a/packages/api-client/src/responses/post.leaderboard.response.ts +++ b/packages/api-client/src/responses/post.leaderboard.response.ts @@ -51,3 +51,5 @@ export class PostLeaderboardResponse { @ApiProperty() public name: string; } + +export class PostGuildScopedPlayerLeaderboardResponse extends PostLeaderboardResponse {} diff --git a/packages/discord/src/arguments/player.argument.ts b/packages/discord/src/arguments/player.argument.ts index 281c6d663..8b3d25204 100644 --- a/packages/discord/src/arguments/player.argument.ts +++ b/packages/discord/src/arguments/player.argument.ts @@ -31,10 +31,16 @@ export class PlayerArgument extends AbstractArgument { public async autocompleteHandler( context: CommandContext ): Promise { - const query = context.option(this.name).toLowerCase(); + const query = context.option(this.name, "").toLowerCase(); const searched = { name: query, value: query }; + if (!query) { + const players = await apiClient.getPlayerAutocomplete(query); + + return players.map((p) => ({ name: p, value: p })); + } + if (query.length > 16) return [searched]; const players = await apiClient.getPlayerAutocomplete(query); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e289028fb..e253f26a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9933,8 +9933,8 @@ snapshots: '@typescript-eslint/parser': 8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2) eslint: 9.34.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.34.0(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.34.0(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.34.0(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.34.0(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.34.0(jiti@2.6.1)) @@ -9953,7 +9953,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.34.0(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1 @@ -9964,22 +9964,22 @@ snapshots: tinyglobby: 0.2.14 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.34.0(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.34.0(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2) eslint: 9.34.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.34.0(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.34.0(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -9990,7 +9990,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.34.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.34.0(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.41.0(eslint@9.34.0(jiti@2.6.1))(typescript@5.9.2))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1)))(eslint@9.34.0(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3