Skip to content
Open
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, LeaderboardScanner } 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 { LeaderboardScanner, Player } 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 (typeof value === "number") {
input = value;
type = LeaderboardQuery.VALUE;
} else {
input = page;
type = LeaderboardQuery.PAGE;
Expand Down
39 changes: 39 additions & 0 deletions apps/api/src/leaderboards/leaderboard.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -302,6 +314,33 @@ export abstract class LeaderboardService {
return response;
}

private async searchLeaderboardValue<T>(
constructor: Constructor<T>,
field: string,
value: number,
sort = "DESC"
): Promise<number> {
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");
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 (typeof value === "number") {
input = value;
type = LeaderboardQuery.VALUE;
} else {
input = page;
type = LeaderboardQuery.PAGE;
Expand Down
4 changes: 3 additions & 1 deletion apps/discord-bot/src/commands/arcade/modes/dropper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { arrayGroup, formatRaceTime, formatTime } from "@statsify/util";
import type { LocalizeFunction } from "@statsify/discord";
import type { ProfileTime } from "#commands/base.hypixel-command";

const formatTimeWithSeconds = (time: number) => formatTime(time, { entries: 3 });

interface DropperTableProps {
stats: Dropper;
submode: SubModeForMode<ArcadeModes, "dropper">;
Expand All @@ -34,7 +36,7 @@ export const DropperTable = ({ stats, submode, t, time }: DropperTableProps) =>
<If condition={stats.bestTime > 0}>
<Table.tr>
<Historical.exclude time={time}>
<Table.td title={t("stats.bestTime")} value={formatTime(stats.bestTime)} color="§d" />
<Table.td title={t("stats.bestTime")} value={formatTimeWithSeconds(stats.bestTime)} color="§d" />
</Historical.exclude>
<Table.td title={t("stats.flawlessGames")} value={t(stats.flawlessGames)} color="§5" />
</Table.tr>
Expand Down
2 changes: 2 additions & 0 deletions apps/discord-bot/src/commands/arcade/modes/farm-hunt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@ export const FarmHuntTable = ({ stats, t }: FarmHuntTableProps) => (
<Table.tr>
<Table.td title={t("stats.wins")} value={t(stats.animalWins)} color="§e" />
<Table.td title={t("stats.kills")} value={t(stats.animalKills)} color="§a" />
<Table.td title={t("stats.killsPerWin")} value={t(stats.animalKillsPerWin)} color="§6" />
</Table.tr>
</Table.ts>
<Table.ts title="§6Hunter">
<Table.tr>
<Table.td title={t("stats.wins")} value={t(stats.hunterWins)} color="§e" />
<Table.td title={t("stats.kills")} value={t(stats.hunterKills)} color="§a" />
<Table.td title={t("stats.killsPerWin")} value={t(stats.hunterKillsPerWin)} color="§6" />
</Table.tr>
</Table.ts>
</Table.table>
Expand Down
3 changes: 3 additions & 0 deletions apps/discord-bot/src/commands/arcade/modes/galaxy-wars.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,8 @@ export const GalaxyWarsTable = ({ stats, t }: GalaxyWarsTableProps) => (
<Table.td title={t("stats.deaths")} value={t(stats.deaths)} color="§c" />
<Table.td title={t("stats.kdr")} value={t(stats.kdr)} color="§6" />
</Table.tr>
<Table.tr>
<Table.td title={t("stats.shotsFired")} value={t(stats.shotsFired)} color="§b" />
</Table.tr>
</Table.table>
);
6 changes: 4 additions & 2 deletions apps/discord-bot/src/commands/arcade/modes/zombies.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@ interface ZombiesMapColumnProps {

const ZombiesMapColumn = ({ title, stats, t, time }: ZombiesMapColumnProps) => {
const mapStat = stats.wins >= 1 ?
[t("stats.fastestWin"), stats.fastestWin ? formatTime(stats.fastestWin) : "N/A"] :
[t("stats.fastestWin"), stats.fastestWin ? formatTime(stats.fastestWin, { entries: 3 }) : "N/A"] :
[t("stats.bestRound"), t(stats.bestRound)];

return (
<Table.ts title={title}>
<Table.td title={t("stats.wins")} value={t(stats.wins)} color="§a" size="small" />
<Table.td title={t("stats.kills")} value={t(stats.kills)} color="§e" size="small" />
<Historical.exclude time={time}>
<Table.td title={mapStat[0]} value={mapStat[1]} color="§e" size="small" />
</Historical.exclude>
Expand All @@ -50,6 +51,7 @@ export const ZombiesTable = ({ stats, t, time }: ZombiesTableProps) => {
<Table.td title={t("stats.wins")} value={t(overall.wins)} color="§a" />
<Table.td title={t("stats.kills")} value={t(overall.kills)} color="§e" />
<Table.td title={t("stats.deaths")} value={t(overall.deaths)} color="§c" />
<Table.td title={t("stats.kdr")} value={t(overall.kdr)} color="§6" />
</Table.tr>
</Table.ts>
<Table.tr>
Expand Down Expand Up @@ -89,7 +91,7 @@ export const ZombiesMapDifficultyTable = ({ stats, t, time }: ZombiesMapDifficul
</Table.tr>
<Historical.exclude time={time}>
<Table.tr>
<Table.td title={t("stats.fastestWin")} value={stats.fastestWin ? formatTime(stats.fastestWin) : "N/A"} color="§b" size="small" />
<Table.td title={t("stats.fastestWin")} value={stats.fastestWin ? formatTime(stats.fastestWin, { entries: 3 }) : "N/A"} color="§b" size="small" />
<Table.td title={t("stats.totalRounds")} value={t(stats.totalRounds)} color="§d" size="small" />
</Table.tr>
</Historical.exclude>
Expand Down
8 changes: 6 additions & 2 deletions apps/discord-bot/src/commands/bedwars/bedwars.profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ export const BedWarsProfile = ({
time,
}: BedWarsProfileProps) => {
const { bedwars } = player.stats;
const stats = bedwars[mode.api];
const modeKey = mode.submode?.api ?? mode.api;
const formattedMode = mode.submode?.api === mode.api || !mode.submode ?
mode.formatted :
`${mode.formatted} ${mode.submode.formatted}`;
const stats = bedwars[modeKey];

const sidebar: SidebarItem[] = [
[t("stats.tokens"), t(bedwars.tokens), "§2"],
Expand All @@ -58,7 +62,7 @@ export const BedWarsProfile = ({
name={player.prefixName}
badge={badge}
sidebar={sidebar}
title={`§l${FormattedGame.BEDWARS} §fStats §r(${mode.formatted})`}
title={`§l${FormattedGame.BEDWARS} §fStats §r(${formattedMode})`}
description={`§7${t("stats.level")}: ${
bedwars.levelFormatted
}\n${formatProgression({
Expand Down
94 changes: 63 additions & 31 deletions apps/discord-bot/src/commands/config/badge.command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
IMessage,
LocalizeFunction,
SubCommand,
TextArgument,
} from "@statsify/discord";
import { type Canvas, Image } from "skia-canvas";
import { DemoProfile } from "./demo.profile.js";
Expand All @@ -41,7 +42,10 @@ export class BadgeCommand {
description: (t) => t("commands.badge-set"),
tier: UserTier.GOLD,
preview: "badge.png",
args: [new FileArgument("badge", true)],
args: [
new FileArgument("badge"),
new TextArgument("emoji", (t) => t("arguments.emoji"), false),
],
})
public set(context: CommandContext) {
return this.run(context, "set");
Expand All @@ -62,6 +66,7 @@ export class BadgeCommand {
): Promise<IMessage> {
const userId = context.getInteraction().getUserId();
const file = context.option<APIAttachment | null>("badge");
const emoji = context.option<string | null>("emoji");
const user = context.getUser();
const t = context.t();

Expand All @@ -82,41 +87,13 @@ export class BadgeCommand {
}

case "set": {
if (!file)
if (!file && !emoji)
throw new ErrorMessage(
(t) => t("errors.unknown.title"),
(t) => t("errors.unknown.description")
);

const canvas = createCanvas(32, 32);
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;

if (!["image/png", "image/jpeg", "image/gif"].includes(file.content_type ?? ""))
throw new ErrorMessage(
(t) => t("errors.unsupportedFileType.title"),
(t) => t("errors.unsupportedFileType.description")
);

const badge = await loadImage(file.url);

const ratio = Math.min(canvas.width / badge.width, canvas.height / badge.height);
const scaled = badge.width > 32 || badge.height > 32;

const width = scaled ? badge.width * ratio : badge.width;
const height = scaled ? badge.height * ratio : badge.height;

ctx.drawImage(
badge,
0,
0,
badge.width,
badge.height,
(canvas.width - width) / 2,
(canvas.height - height) / 2,
width,
height
);
const canvas = file ? await this.getBadgeCanvas(file) : await this.getEmojiCanvas(emoji as string);

await this.apiService.updateUserBadge(userId, await canvas.toBuffer("png"));
const profile = await this.getProfile(t, user, canvas);
Expand All @@ -141,6 +118,61 @@ export class BadgeCommand {
}
}

private async getBadgeCanvas(file: APIAttachment) {
const canvas = createCanvas(32, 32);
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;

if (!["image/png", "image/jpeg", "image/gif"].includes(file.content_type ?? ""))
throw new ErrorMessage(
(t) => t("errors.unsupportedFileType.title"),
(t) => t("errors.unsupportedFileType.description")
);

const badge = await loadImage(file.url);

const ratio = Math.min(canvas.width / badge.width, canvas.height / badge.height);
const scaled = badge.width > 32 || badge.height > 32;

const width = scaled ? badge.width * ratio : badge.width;
const height = scaled ? badge.height * ratio : badge.height;

ctx.drawImage(
badge,
0,
0,
badge.width,
badge.height,
(canvas.width - width) / 2,
(canvas.height - height) / 2,
width,
height
);

return canvas;
}

private async getEmojiCanvas(input: string) {
const canvas = createCanvas(32, 32);
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;

const customEmoji = input.trim().match(/^<a?:\w+:(\d+)>$/);

if (customEmoji) {
const badge = await loadImage(`https://cdn.discordapp.com/emojis/${customEmoji[1]}.png?size=32&quality=lossless`);
ctx.drawImage(badge, 0, 0, badge.width, badge.height, 0, 0, 32, 32);
return canvas;
}

ctx.font = "28px Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(input.trim(), 16, 17);

return canvas;
}

private async getProfile(t: LocalizeFunction, user: User, badge?: Image | Canvas) {
if (!user?.uuid) throw new ErrorMessage("errors.unknown");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { CopsAndCrimsModes, FormattedGame, type GameMode } from "@statsify/schem
import { formatTime } from "@statsify/util";
import type { BaseProfileProps } from "#commands/base.hypixel-command";

const formatTimeWithSeconds = (time: number) => formatTime(time, { entries: 3 });

export interface CopsAndCrimsProfileProps extends BaseProfileProps {
mode: GameMode<CopsAndCrimsModes>;
}
Expand Down Expand Up @@ -105,7 +107,7 @@ export const CopsAndCrimsProfile = ({
<Historical.exclude time={time}>
<Table.td
title={t("stats.bestTime")}
value={formatTime(stats.fastestWin)}
value={formatTimeWithSeconds(stats.fastestWin)}
color="§b"
/>
</Historical.exclude>
Expand Down
Loading
Loading