Compare commits
2 Commits
7421c47959
...
c5bfdc8b9c
Author | SHA1 | Date | |
---|---|---|---|
c5bfdc8b9c | |||
c40b8b5d8e |
@ -3,6 +3,7 @@ import { PlayerService } from "../service/player.service";
|
|||||||
import { t } from "elysia";
|
import { t } from "elysia";
|
||||||
import { PlayerHistory } from "@ssr/common/player/player-history";
|
import { PlayerHistory } from "@ssr/common/player/player-history";
|
||||||
import { PlayerTrackedSince } from "@ssr/common/player/player-tracked-since";
|
import { PlayerTrackedSince } from "@ssr/common/player/player-tracked-since";
|
||||||
|
import { AroundPlayerResponse } from "@ssr/common/response/around-player-response";
|
||||||
|
|
||||||
@Controller("/player")
|
@Controller("/player")
|
||||||
export default class PlayerController {
|
export default class PlayerController {
|
||||||
@ -51,4 +52,21 @@ export default class PlayerController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("/around/:id/:type", {
|
||||||
|
config: {},
|
||||||
|
params: t.Object({
|
||||||
|
id: t.String({ required: true }),
|
||||||
|
type: t.String({ required: true }),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
public async getPlayersAround({
|
||||||
|
params: { id, type },
|
||||||
|
}: {
|
||||||
|
params: { id: string; type: "global" | "country" };
|
||||||
|
}): Promise<AroundPlayerResponse> {
|
||||||
|
return {
|
||||||
|
players: await PlayerService.getAround(id, type),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,12 +5,11 @@ import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
|||||||
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
|
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
|
||||||
import { InternalServerError } from "../error/internal-server-error";
|
import { InternalServerError } from "../error/internal-server-error";
|
||||||
import ScoreSaberPlayerScoreToken from "@ssr/common/types/token/scoresaber/score-saber-player-score-token";
|
import ScoreSaberPlayerScoreToken from "@ssr/common/types/token/scoresaber/score-saber-player-score-token";
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
import { formatPp } from "@ssr/common/utils/number-utils";
|
import { formatPp } from "@ssr/common/utils/number-utils";
|
||||||
import { isProduction } from "@ssr/common/utils/utils";
|
import { getPageFromRank, isProduction } from "@ssr/common/utils/utils";
|
||||||
import { DiscordChannels, logToChannel } from "../bot/bot";
|
import { DiscordChannels, logToChannel } from "../bot/bot";
|
||||||
import { EmbedBuilder } from "discord.js";
|
import { EmbedBuilder } from "discord.js";
|
||||||
|
import { AroundPlayer } from "@ssr/common/types/around-player";
|
||||||
|
|
||||||
export class PlayerService {
|
export class PlayerService {
|
||||||
/**
|
/**
|
||||||
@ -197,4 +196,69 @@ export class PlayerService {
|
|||||||
`Updated scores set statistic for "${playerName}"(${playerId}), scores today: ${scores.rankedScores} ranked, ${scores.unrankedScores} unranked`
|
`Updated scores set statistic for "${playerName}"(${playerId}), scores today: ${scores.rankedScores} ranked, ${scores.unrankedScores} unranked`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the players around a player.
|
||||||
|
*
|
||||||
|
* @param id the player to get around
|
||||||
|
* @param type the type to get around
|
||||||
|
*/
|
||||||
|
public static async getAround(id: string, type: AroundPlayer): Promise<ScoreSaberPlayerToken[]> {
|
||||||
|
const getRank = (player: ScoreSaberPlayerToken, type: AroundPlayer) => {
|
||||||
|
switch (type) {
|
||||||
|
case "global":
|
||||||
|
return player.rank;
|
||||||
|
case "country":
|
||||||
|
return player.countryRank;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemsPerPage = 50;
|
||||||
|
const player = await scoresaberService.lookupPlayer(id, true);
|
||||||
|
if (player == undefined) {
|
||||||
|
throw new NotFoundError(`Player "${id}" not found`);
|
||||||
|
}
|
||||||
|
const rank = getRank(player, type);
|
||||||
|
const rankWithinPage = rank % itemsPerPage;
|
||||||
|
|
||||||
|
const pagesToSearch = [getPageFromRank(rank, itemsPerPage)];
|
||||||
|
if (rankWithinPage > 0) {
|
||||||
|
pagesToSearch.push(getPageFromRank(rank - 1, itemsPerPage));
|
||||||
|
} else if (rankWithinPage < itemsPerPage - 1) {
|
||||||
|
pagesToSearch.push(getPageFromRank(rank + 1, itemsPerPage));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rankings: Map<string, ScoreSaberPlayerToken> = new Map();
|
||||||
|
for (const page of pagesToSearch) {
|
||||||
|
const response =
|
||||||
|
type == "global"
|
||||||
|
? await scoresaberService.lookupPlayers(page)
|
||||||
|
: await scoresaberService.lookupPlayersByCountry(page, player.country);
|
||||||
|
if (response == undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const player of response.players) {
|
||||||
|
if (rankings.has(player.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rankings.set(player.id, player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const players = rankings
|
||||||
|
.values()
|
||||||
|
.toArray()
|
||||||
|
.sort((a, b) => {
|
||||||
|
return getRank(a, type) - getRank(b, type);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show 3 players above and 1 below the requested player
|
||||||
|
const playerPosition = players.findIndex(p => p.id === player.id);
|
||||||
|
const start = Math.max(0, playerPosition - 3);
|
||||||
|
const end = Math.min(players.length, playerPosition + 2);
|
||||||
|
|
||||||
|
return players.slice(start, end);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
import ScoreSaberPlayerScoreToken from "@ssr/common/types/token/scoresaber/score-saber-player-score-token";
|
import ScoreSaberPlayerScoreToken from "@ssr/common/types/token/scoresaber/score-saber-player-score-token";
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
import { formatNumberWithCommas, formatPp } from "@ssr/common/utils/number-utils";
|
import { formatNumberWithCommas, formatPp } from "@ssr/common/utils/number-utils";
|
||||||
import { isProduction } from "@ssr/common/utils/utils";
|
import { isProduction } from "@ssr/common/utils/utils";
|
||||||
import { Metadata } from "@ssr/common/types/metadata";
|
import { Metadata } from "@ssr/common/types/metadata";
|
||||||
|
8
projects/common/src/response/around-player-response.ts
Normal file
8
projects/common/src/response/around-player-response.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import ScoreSaberPlayerToken from "../types/token/scoresaber/score-saber-player-token";
|
||||||
|
|
||||||
|
export type AroundPlayerResponse = {
|
||||||
|
/**
|
||||||
|
* The players around the player.
|
||||||
|
*/
|
||||||
|
players: ScoreSaberPlayerToken[];
|
||||||
|
};
|
1
projects/common/src/types/around-player.ts
Normal file
1
projects/common/src/types/around-player.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export type AroundPlayer = "global" | "country";
|
@ -1,6 +1,8 @@
|
|||||||
import { PlayerHistory } from "../player/player-history";
|
import { PlayerHistory } from "../player/player-history";
|
||||||
import { kyFetch } from "./utils";
|
import { kyFetch } from "./utils";
|
||||||
import { Config } from "../config";
|
import { Config } from "../config";
|
||||||
|
import { AroundPlayer } from "../types/around-player";
|
||||||
|
import { AroundPlayerResponse } from "../response/around-player-response";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sorts the player history based on date,
|
* Sorts the player history based on date,
|
||||||
@ -22,3 +24,13 @@ export function sortPlayerHistory(history: Map<string, PlayerHistory>) {
|
|||||||
export async function trackPlayer(id: string) {
|
export async function trackPlayer(id: string) {
|
||||||
await kyFetch(`${Config.apiUrl}/player/history/${id}?createIfMissing=true`);
|
await kyFetch(`${Config.apiUrl}/player/history/${id}?createIfMissing=true`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the players around a player.
|
||||||
|
*
|
||||||
|
* @param id the player to get around
|
||||||
|
* @param type the type to get
|
||||||
|
*/
|
||||||
|
export async function getPlayersAroundPlayer(id: string, type: AroundPlayer) {
|
||||||
|
return await kyFetch<AroundPlayerResponse>(`${Config.apiUrl}/player/around/${id}/${type}`);
|
||||||
|
}
|
||||||
|
@ -28,10 +28,10 @@ export function delay(ms: number) {
|
|||||||
*
|
*
|
||||||
* @param rank the rank
|
* @param rank the rank
|
||||||
* @param itemsPerPage the items per page
|
* @param itemsPerPage the items per page
|
||||||
* @returns the page
|
* @returns the page number
|
||||||
*/
|
*/
|
||||||
export function getPageFromRank(rank: number, itemsPerPage: number) {
|
export function getPageFromRank(rank: number, itemsPerPage: number) {
|
||||||
return Math.floor(rank / itemsPerPage) + 1;
|
return Math.floor((rank - 1) / itemsPerPage) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -21,7 +21,7 @@ type ScoresaberSocket = {
|
|||||||
*
|
*
|
||||||
* @param error the error that caused the connection to close
|
* @param error the error that caused the connection to close
|
||||||
*/
|
*/
|
||||||
onDisconnect?: (error?: WebSocket.ErrorEvent) => void;
|
onDisconnect?: (error?: WebSocket.ErrorEvent | WebSocket.CloseEvent) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -46,10 +46,10 @@ export function connectScoreSaberWebSocket({ onMessage, onScore, onDisconnect }:
|
|||||||
onDisconnect && onDisconnect(error);
|
onDisconnect && onDisconnect(error);
|
||||||
};
|
};
|
||||||
|
|
||||||
websocket.onclose = () => {
|
websocket.onclose = event => {
|
||||||
console.log("Lost connection to the ScoreSaber WebSocket. Attempting to reconnect...");
|
console.log("Lost connection to the ScoreSaber WebSocket. Attempting to reconnect...");
|
||||||
|
|
||||||
onDisconnect && onDisconnect();
|
onDisconnect && onDisconnect(event);
|
||||||
setTimeout(connectWs, 5000); // Reconnect after 5 seconds
|
setTimeout(connectWs, 5000); // Reconnect after 5 seconds
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -8,10 +8,8 @@ import CountryFlag from "../country-flag";
|
|||||||
import { Avatar, AvatarImage } from "../ui/avatar";
|
import { Avatar, AvatarImage } from "../ui/avatar";
|
||||||
import { PlayerRankingSkeleton } from "@/components/ranking/player-ranking-skeleton";
|
import { PlayerRankingSkeleton } from "@/components/ranking/player-ranking-skeleton";
|
||||||
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
|
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
|
||||||
import { ScoreSaberPlayersPageToken } from "@ssr/common/types/token/scoresaber/score-saber-players-page-token";
|
import { getPlayersAroundPlayer } from "@ssr/common/utils/player-utils";
|
||||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
import { AroundPlayer } from "@ssr/common/types/around-player";
|
||||||
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
|
|
||||||
import { getPageFromRank } from "@ssr/common/utils/utils";
|
|
||||||
|
|
||||||
const PLAYER_NAME_MAX_LENGTH = 18;
|
const PLAYER_NAME_MAX_LENGTH = 18;
|
||||||
|
|
||||||
@ -34,42 +32,18 @@ type MiniProps = {
|
|||||||
|
|
||||||
type Variants = {
|
type Variants = {
|
||||||
[key: string]: {
|
[key: string]: {
|
||||||
itemsPerPage: number;
|
|
||||||
icon: (player: ScoreSaberPlayer) => ReactElement;
|
icon: (player: ScoreSaberPlayer) => ReactElement;
|
||||||
getPage: (player: ScoreSaberPlayer, itemsPerPage: number) => number;
|
|
||||||
getRank: (player: ScoreSaberPlayer) => number;
|
|
||||||
query: (page: number, country: string) => Promise<ScoreSaberPlayersPageToken | undefined>;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const miniVariants: Variants = {
|
const miniVariants: Variants = {
|
||||||
Global: {
|
Global: {
|
||||||
itemsPerPage: 50,
|
|
||||||
icon: () => <GlobeAmericasIcon className="w-6 h-6" />,
|
icon: () => <GlobeAmericasIcon className="w-6 h-6" />,
|
||||||
getPage: (player: ScoreSaberPlayer, itemsPerPage: number) => {
|
|
||||||
return getPageFromRank(player.rank - 1, itemsPerPage);
|
|
||||||
},
|
|
||||||
getRank: (player: ScoreSaberPlayer) => {
|
|
||||||
return player.rank;
|
|
||||||
},
|
|
||||||
query: (page: number) => {
|
|
||||||
return scoresaberService.lookupPlayers(page);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
Country: {
|
Country: {
|
||||||
itemsPerPage: 50,
|
|
||||||
icon: (player: ScoreSaberPlayer) => {
|
icon: (player: ScoreSaberPlayer) => {
|
||||||
return <CountryFlag code={player.country} size={12} />;
|
return <CountryFlag code={player.country} size={12} />;
|
||||||
},
|
},
|
||||||
getPage: (player: ScoreSaberPlayer, itemsPerPage: number) => {
|
|
||||||
return getPageFromRank(player.countryRank - 1, itemsPerPage);
|
|
||||||
},
|
|
||||||
getRank: (player: ScoreSaberPlayer) => {
|
|
||||||
return player.countryRank;
|
|
||||||
},
|
|
||||||
query: (page: number, country: string) => {
|
|
||||||
return scoresaberService.lookupPlayersByCountry(page, country);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -80,41 +54,18 @@ export default function Mini({ type, player, shouldUpdate }: MiniProps) {
|
|||||||
|
|
||||||
const variant = miniVariants[type];
|
const variant = miniVariants[type];
|
||||||
const icon = variant.icon(player);
|
const icon = variant.icon(player);
|
||||||
const itemsPerPage = variant.itemsPerPage;
|
|
||||||
|
|
||||||
// Calculate the page and the rank of the player within that page
|
const {
|
||||||
const page = variant.getPage(player, itemsPerPage);
|
data: response,
|
||||||
const rankWithinPage = variant.getRank(player) % itemsPerPage;
|
isLoading,
|
||||||
|
isError,
|
||||||
const { data, isLoading, isError } = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["mini-ranking-" + type, player.id, type, page],
|
queryKey: ["mini-ranking-" + type, player.id, type],
|
||||||
queryFn: async () => {
|
queryFn: () => getPlayersAroundPlayer(player.id, type.toLowerCase() as AroundPlayer),
|
||||||
const pagesToSearch = [page];
|
|
||||||
if (rankWithinPage < 5 && page > 1) {
|
|
||||||
pagesToSearch.push(page - 1);
|
|
||||||
}
|
|
||||||
if (rankWithinPage > itemsPerPage - 5) {
|
|
||||||
pagesToSearch.push(page + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const players: ScoreSaberPlayerToken[] = [];
|
|
||||||
for (const p of pagesToSearch) {
|
|
||||||
const response = await variant.query(p, player.country);
|
|
||||||
if (response) {
|
|
||||||
players.push(...response.players);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort players by rank to ensure correct order
|
|
||||||
return players.sort((a, b) => {
|
|
||||||
// This is the wrong type but it still works.
|
|
||||||
return variant.getRank(a as unknown as ScoreSaberPlayer) - variant.getRank(b as unknown as ScoreSaberPlayer);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
enabled: shouldUpdate,
|
enabled: shouldUpdate,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading || !data) {
|
if (isLoading || !response) {
|
||||||
return <PlayerRankingSkeleton />;
|
return <PlayerRankingSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,27 +73,6 @@ export default function Mini({ type, player, shouldUpdate }: MiniProps) {
|
|||||||
return <p className="text-red-500">Error loading ranking</p>;
|
return <p className="text-red-500">Error loading ranking</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let players = data;
|
|
||||||
const playerPosition = players.findIndex(p => p.id === player.id);
|
|
||||||
|
|
||||||
// Always show 5 players: 3 above and 1 below the player
|
|
||||||
const start = Math.max(0, playerPosition - 3);
|
|
||||||
const end = Math.min(players.length, start + 5);
|
|
||||||
|
|
||||||
players = players.slice(start, end);
|
|
||||||
|
|
||||||
// If fewer than 5 players, append/prepend more
|
|
||||||
if (players.length < 5) {
|
|
||||||
const missingPlayers = 5 - players.length;
|
|
||||||
if (start === 0) {
|
|
||||||
const additionalPlayers = players.slice(playerPosition + 1, playerPosition + 1 + missingPlayers);
|
|
||||||
players = [...players, ...additionalPlayers];
|
|
||||||
} else if (end === players.length) {
|
|
||||||
const additionalPlayers = players.slice(Math.max(0, start - missingPlayers), start);
|
|
||||||
players = [...additionalPlayers, ...players];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full flex gap-2 sticky select-none">
|
<Card className="w-full flex gap-2 sticky select-none">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@ -150,7 +80,7 @@ export default function Mini({ type, player, shouldUpdate }: MiniProps) {
|
|||||||
<p>{type} Ranking</p>
|
<p>{type} Ranking</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col text-sm">
|
<div className="flex flex-col text-sm">
|
||||||
{players.map((playerRanking, index) => {
|
{response.players.map((playerRanking, index) => {
|
||||||
const rank = type == "Global" ? playerRanking.rank : playerRanking.countryRank;
|
const rank = type == "Global" ? playerRanking.rank : playerRanking.countryRank;
|
||||||
const playerName =
|
const playerName =
|
||||||
playerRanking.name.length > PLAYER_NAME_MAX_LENGTH
|
playerRanking.name.length > PLAYER_NAME_MAX_LENGTH
|
||||||
|
Reference in New Issue
Block a user