add daily scores set tracking
This commit is contained in:
parent
5b3218c205
commit
9d38e095fe
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
@ -18,6 +18,7 @@ import { cron } from "@elysiajs/cron";
|
|||||||
import { PlayerDocument, PlayerModel } from "./model/player";
|
import { PlayerDocument, PlayerModel } from "./model/player";
|
||||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||||
import { delay } from "@ssr/common/utils/utils";
|
import { delay } from "@ssr/common/utils/utils";
|
||||||
|
import { connectScoreSaberWebSocket } from "@ssr/common/websocket/scoresaber-websocket";
|
||||||
|
|
||||||
// Load .env file
|
// Load .env file
|
||||||
dotenv.config({
|
dotenv.config({
|
||||||
@ -28,8 +29,14 @@ dotenv.config({
|
|||||||
|
|
||||||
await mongoose.connect(Config.mongoUri!); // Connect to MongoDB
|
await mongoose.connect(Config.mongoUri!); // Connect to MongoDB
|
||||||
setLogLevel("DEBUG");
|
setLogLevel("DEBUG");
|
||||||
export const app = new Elysia();
|
|
||||||
|
|
||||||
|
connectScoreSaberWebSocket({
|
||||||
|
onScore: async score => {
|
||||||
|
await PlayerService.trackScore(score);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const app = new Elysia();
|
||||||
app.use(
|
app.use(
|
||||||
cron({
|
cron({
|
||||||
name: "player-statistics-tracker-cron",
|
name: "player-statistics-tracker-cron",
|
||||||
|
@ -62,13 +62,13 @@ export class Player {
|
|||||||
public getHistoryPreviousDays(days: number): Record<string, PlayerHistory> {
|
public getHistoryPreviousDays(days: number): Record<string, PlayerHistory> {
|
||||||
const statisticHistory = this.getStatisticHistory();
|
const statisticHistory = this.getStatisticHistory();
|
||||||
const history: Record<string, PlayerHistory> = {};
|
const history: Record<string, PlayerHistory> = {};
|
||||||
|
|
||||||
for (let i = 0; i < days; i++) {
|
for (let i = 0; i < days; i++) {
|
||||||
const date = formatDateMinimal(getMidnightAlignedDate(getDaysAgoDate(i)));
|
const date = formatDateMinimal(getMidnightAlignedDate(getDaysAgoDate(i)));
|
||||||
const playerHistory = statisticHistory[date];
|
const playerHistory = statisticHistory[date];
|
||||||
if (playerHistory === undefined || Object.keys(playerHistory).length === 0) {
|
if (playerHistory !== undefined && Object.keys(playerHistory).length > 0) {
|
||||||
continue;
|
history[date] = playerHistory;
|
||||||
}
|
}
|
||||||
history[date] = playerHistory;
|
|
||||||
}
|
}
|
||||||
return history;
|
return history;
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ import { getDaysAgoDate, getMidnightAlignedDate } from "@ssr/common/utils/time-u
|
|||||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
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";
|
||||||
|
|
||||||
export class PlayerService {
|
export class PlayerService {
|
||||||
/**
|
/**
|
||||||
@ -113,4 +114,44 @@ export class PlayerService {
|
|||||||
|
|
||||||
console.log(`Tracked player "${foundPlayer.id}"!`);
|
console.log(`Tracked player "${foundPlayer.id}"!`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track player score.
|
||||||
|
*
|
||||||
|
* @param score the score to track
|
||||||
|
* @param leaderboard the leaderboard to track
|
||||||
|
*/
|
||||||
|
public static async trackScore({ score, leaderboard }: ScoreSaberPlayerScoreToken) {
|
||||||
|
const playerId = score.leaderboardPlayerInfo.id;
|
||||||
|
const player: PlayerDocument | null = await PlayerModel.findById(playerId);
|
||||||
|
// Player is not tracked, so ignore the score.
|
||||||
|
if (player == undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
let history = player.getHistoryByDate(today);
|
||||||
|
if (history == undefined || Object.keys(history).length === 0) {
|
||||||
|
history = { scores: { rankedScores: 0, unrankedScores: 0 } }; // Ensure initialization
|
||||||
|
}
|
||||||
|
|
||||||
|
const scores = history.scores || {};
|
||||||
|
if (leaderboard.stars > 0) {
|
||||||
|
scores.rankedScores!++;
|
||||||
|
} else {
|
||||||
|
scores.unrankedScores!++;
|
||||||
|
}
|
||||||
|
|
||||||
|
history.scores = scores;
|
||||||
|
player.setStatisticHistory(today, history);
|
||||||
|
player.sortStatisticHistory();
|
||||||
|
|
||||||
|
// Save the changes
|
||||||
|
player.markModified("statisticHistory");
|
||||||
|
await player.save();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Updated scores set statistic for "${player.id}", scores today: ${scores.rankedScores} ranked, ${scores.unrankedScores} unranked`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,6 +19,7 @@
|
|||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ky": "^1.7.2"
|
"ky": "^1.7.2",
|
||||||
|
"ws": "^8.18.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -110,6 +110,13 @@ export async function getScoreSaberPlayerFromToken(
|
|||||||
if (history) {
|
if (history) {
|
||||||
// Use the latest data for today
|
// Use the latest data for today
|
||||||
history[todayDate] = {
|
history[todayDate] = {
|
||||||
|
...{
|
||||||
|
scores: {
|
||||||
|
rankedScores: 0,
|
||||||
|
unrankedScores: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...history[todayDate],
|
||||||
rank: token.rank,
|
rank: token.rank,
|
||||||
countryRank: token.countryRank,
|
countryRank: token.countryRank,
|
||||||
pp: token.pp,
|
pp: token.pp,
|
||||||
@ -133,15 +140,17 @@ export async function getScoreSaberPlayerFromToken(
|
|||||||
for (let i = playerRankHistory.length - 1; i >= 0; i--) {
|
for (let i = playerRankHistory.length - 1; i >= 0; i--) {
|
||||||
const rank = playerRankHistory[i];
|
const rank = playerRankHistory[i];
|
||||||
const date = getMidnightAlignedDate(getDaysAgoDate(daysAgo));
|
const date = getMidnightAlignedDate(getDaysAgoDate(daysAgo));
|
||||||
daysAgo += 1; // Increment daysAgo for each earlier rank
|
daysAgo += 1;
|
||||||
|
|
||||||
if (statisticHistory[formatDateMinimal(date)] == undefined) {
|
const dateKey = formatDateMinimal(date);
|
||||||
|
if (!statisticHistory[dateKey]) {
|
||||||
missingDays += 1;
|
missingDays += 1;
|
||||||
statisticHistory[formatDateMinimal(date)] = {
|
statisticHistory[dateKey] = {
|
||||||
rank: rank,
|
rank: rank,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (missingDays > 0 && missingDays != playerRankHistory.length) {
|
if (missingDays > 0 && missingDays != playerRankHistory.length) {
|
||||||
console.log(
|
console.log(
|
||||||
`Player has ${missingDays} missing day${missingDays > 1 ? "s" : ""}, filling in with fallback history...`
|
`Player has ${missingDays} missing day${missingDays > 1 ? "s" : ""}, filling in with fallback history...`
|
||||||
|
@ -14,6 +14,21 @@ export interface PlayerHistory {
|
|||||||
*/
|
*/
|
||||||
pp?: number;
|
pp?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of scores set for this day.
|
||||||
|
*/
|
||||||
|
scores?: {
|
||||||
|
/**
|
||||||
|
* The amount of score set.
|
||||||
|
*/
|
||||||
|
rankedScores?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of unranked scores set.
|
||||||
|
*/
|
||||||
|
unrankedScores?: number;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The player's accuracy.
|
* The player's accuracy.
|
||||||
*/
|
*/
|
||||||
|
64
projects/common/src/websocket/scoresaber-websocket.ts
Normal file
64
projects/common/src/websocket/scoresaber-websocket.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import WebSocket from "ws";
|
||||||
|
import ScoreSaberPlayerScoreToken from "../types/token/scoresaber/score-saber-player-score-token";
|
||||||
|
|
||||||
|
type ScoresaberWebsocket = {
|
||||||
|
/**
|
||||||
|
* Invoked when a general message is received.
|
||||||
|
*
|
||||||
|
* @param message The received message.
|
||||||
|
*/
|
||||||
|
onMessage?: (message: unknown) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoked when a score message is received.
|
||||||
|
*
|
||||||
|
* @param score The received score data.
|
||||||
|
*/
|
||||||
|
onScore?: (score: ScoreSaberPlayerScoreToken) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects to the ScoreSaber WebSocket and handles incoming messages.
|
||||||
|
*/
|
||||||
|
export function connectScoreSaberWebSocket({ onMessage, onScore }: ScoresaberWebsocket) {
|
||||||
|
let websocket = connectWs();
|
||||||
|
|
||||||
|
websocket.onopen = () => {
|
||||||
|
console.log("Connected to the ScoreSaber WebSocket!");
|
||||||
|
};
|
||||||
|
|
||||||
|
websocket.onerror = error => {
|
||||||
|
console.error("WebSocket Error:", error);
|
||||||
|
};
|
||||||
|
|
||||||
|
websocket.onclose = () => {
|
||||||
|
console.log("Lost connection to the ScoreSaber WebSocket. Reconnecting in 5 seconds...");
|
||||||
|
setTimeout(() => {
|
||||||
|
websocket = connectWs();
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
websocket.onmessage = messageEvent => {
|
||||||
|
if (typeof messageEvent.data !== "string") return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const command = JSON.parse(messageEvent.data);
|
||||||
|
|
||||||
|
if (command.commandName === "score") {
|
||||||
|
onScore && onScore(command.commandData as ScoreSaberPlayerScoreToken);
|
||||||
|
} else {
|
||||||
|
onMessage && onMessage(command);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Received invalid message:", messageEvent.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes and returns a new WebSocket connection to ScoreSaber.
|
||||||
|
*/
|
||||||
|
function connectWs(): WebSocket {
|
||||||
|
console.log("Connecting to the ScoreSaber WebSocket...");
|
||||||
|
return new WebSocket("wss://scoresaber.com/ws");
|
||||||
|
}
|
@ -1,14 +1,25 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CategoryScale, Chart, Legend, LinearScale, LineElement, PointElement, Title, Tooltip } from "chart.js";
|
import {
|
||||||
|
BarElement,
|
||||||
|
CategoryScale,
|
||||||
|
Chart,
|
||||||
|
Legend,
|
||||||
|
LinearScale,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
} from "chart.js";
|
||||||
import { Line } from "react-chartjs-2";
|
import { Line } from "react-chartjs-2";
|
||||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||||
import { formatDateMinimal, getDaysAgoDate, parseDate } from "@ssr/common/utils/time-utils";
|
import { formatDateMinimal, getDaysAgo, getDaysAgoDate, parseDate } from "@ssr/common/utils/time-utils";
|
||||||
|
|
||||||
Chart.register(LinearScale, CategoryScale, PointElement, LineElement, Title, Tooltip, Legend);
|
Chart.register(LinearScale, CategoryScale, PointElement, LineElement, BarElement, Title, Tooltip, Legend);
|
||||||
|
|
||||||
export type AxisPosition = "left" | "right";
|
export type AxisPosition = "left" | "right";
|
||||||
|
export type DatasetDisplayType = "line" | "bar";
|
||||||
|
|
||||||
export type Axis = {
|
export type Axis = {
|
||||||
id?: string;
|
id?: string;
|
||||||
@ -33,6 +44,7 @@ export type Dataset = {
|
|||||||
lineTension: number;
|
lineTension: number;
|
||||||
spanGaps: boolean;
|
spanGaps: boolean;
|
||||||
yAxisID: string;
|
yAxisID: string;
|
||||||
|
type?: DatasetDisplayType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DatasetConfig = {
|
export type DatasetConfig = {
|
||||||
@ -48,6 +60,7 @@ export type DatasetConfig = {
|
|||||||
position: AxisPosition;
|
position: AxisPosition;
|
||||||
valueFormatter?: (value: number) => string; // Added precision option here
|
valueFormatter?: (value: number) => string; // Added precision option here
|
||||||
};
|
};
|
||||||
|
type?: DatasetDisplayType;
|
||||||
labelFormatter: (value: number) => string;
|
labelFormatter: (value: number) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -80,7 +93,13 @@ const generateAxis = (
|
|||||||
reverse,
|
reverse,
|
||||||
});
|
});
|
||||||
|
|
||||||
const generateDataset = (label: string, data: (number | null)[], borderColor: string, yAxisID: string): Dataset => ({
|
const generateDataset = (
|
||||||
|
label: string,
|
||||||
|
data: (number | null)[],
|
||||||
|
borderColor: string,
|
||||||
|
yAxisID: string,
|
||||||
|
type?: DatasetDisplayType
|
||||||
|
): Dataset => ({
|
||||||
label,
|
label,
|
||||||
data,
|
data,
|
||||||
borderColor,
|
borderColor,
|
||||||
@ -88,6 +107,10 @@ const generateDataset = (label: string, data: (number | null)[], borderColor: st
|
|||||||
lineTension: 0.5,
|
lineTension: 0.5,
|
||||||
spanGaps: false,
|
spanGaps: false,
|
||||||
yAxisID,
|
yAxisID,
|
||||||
|
type,
|
||||||
|
...(type === "bar" && {
|
||||||
|
backgroundColor: borderColor,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function GenericChart({ labels, datasetConfig, histories }: ChartProps) {
|
export default function GenericChart({ labels, datasetConfig, histories }: ChartProps) {
|
||||||
@ -130,7 +153,7 @@ export default function GenericChart({ labels, datasetConfig, histories }: Chart
|
|||||||
config.axisConfig.valueFormatter
|
config.axisConfig.valueFormatter
|
||||||
);
|
);
|
||||||
|
|
||||||
return generateDataset(config.title, historyArray, config.color, config.axisId);
|
return generateDataset(config.title, historyArray, config.color, config.axisId, config.type || "line");
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@ -149,12 +172,10 @@ export default function GenericChart({ labels, datasetConfig, histories }: Chart
|
|||||||
callbacks: {
|
callbacks: {
|
||||||
title(context: any) {
|
title(context: any) {
|
||||||
const date = labels[context[0].dataIndex];
|
const date = labels[context[0].dataIndex];
|
||||||
const currentDate = new Date();
|
const differenceInDays = getDaysAgo(date);
|
||||||
const differenceInTime = currentDate.getTime() - new Date(date).getTime();
|
|
||||||
const differenceInDays = Math.ceil(differenceInTime / (1000 * 3600 * 24)) - 1;
|
|
||||||
let formattedDate: string;
|
let formattedDate: string;
|
||||||
if (differenceInDays === 0) {
|
if (differenceInDays === 0) {
|
||||||
formattedDate = "Today";
|
formattedDate = "Now";
|
||||||
} else if (differenceInDays === 1) {
|
} else if (differenceInDays === 1) {
|
||||||
formattedDate = "Yesterday";
|
formattedDate = "Yesterday";
|
||||||
} else {
|
} else {
|
||||||
@ -174,17 +195,18 @@ export default function GenericChart({ labels, datasetConfig, histories }: Chart
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formattedLabels = labels.map(date => {
|
const formattedLabels = labels.map(date => {
|
||||||
if (formatDateMinimal(getDaysAgoDate(0)) === formatDateMinimal(date)) {
|
const formattedDate = formatDateMinimal(date);
|
||||||
|
if (formatDateMinimal(getDaysAgoDate(0)) === formattedDate) {
|
||||||
return "Now";
|
return "Now";
|
||||||
}
|
}
|
||||||
if (formatDateMinimal(getDaysAgoDate(1)) === formatDateMinimal(date)) {
|
if (formatDateMinimal(getDaysAgoDate(1)) === formattedDate) {
|
||||||
return "Yesterday";
|
return "Yesterday";
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatDateMinimal(date);
|
return formattedDate;
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = { labels: formattedLabels, datasets };
|
const data: any = { labels: formattedLabels, datasets };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="block h-[360px] w-full relative">
|
<div className="block h-[360px] w-full relative">
|
||||||
|
@ -29,13 +29,14 @@ export default function GenericPlayerChart({ player, datasetConfig }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const histories: Record<string, (number | null)[]> = {};
|
const histories: Record<string, (number | null)[]> = {};
|
||||||
// Initialize histories for each dataset
|
const historyDays = 50;
|
||||||
|
|
||||||
|
// Initialize histories for each dataset with null values for all days
|
||||||
datasetConfig.forEach(config => {
|
datasetConfig.forEach(config => {
|
||||||
histories[config.field] = [];
|
histories[config.field] = Array(historyDays).fill(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
const labels: Date[] = [];
|
const labels: Date[] = [];
|
||||||
const historyDays = 50;
|
|
||||||
|
|
||||||
// Sort the statistic entries by date
|
// Sort the statistic entries by date
|
||||||
const statisticEntries = Object.entries(player.statisticHistory).sort(
|
const statisticEntries = Object.entries(player.statisticHistory).sort(
|
||||||
@ -49,9 +50,7 @@ export default function GenericPlayerChart({ player, datasetConfig }: Props) {
|
|||||||
for (let dayAgo = historyDays - 1; dayAgo >= 0; dayAgo--) {
|
for (let dayAgo = historyDays - 1; dayAgo >= 0; dayAgo--) {
|
||||||
const targetDate = new Date();
|
const targetDate = new Date();
|
||||||
targetDate.setDate(today.getDate() - dayAgo);
|
targetDate.setDate(today.getDate() - dayAgo);
|
||||||
|
labels.push(targetDate); // Push the target date to labels
|
||||||
// Find if there's a matching entry for this date
|
|
||||||
let matchedEntry = false;
|
|
||||||
|
|
||||||
// Check if currentHistoryIndex is within bounds of statisticEntries
|
// Check if currentHistoryIndex is within bounds of statisticEntries
|
||||||
if (currentHistoryIndex < statisticEntries.length) {
|
if (currentHistoryIndex < statisticEntries.length) {
|
||||||
@ -61,20 +60,12 @@ export default function GenericPlayerChart({ player, datasetConfig }: Props) {
|
|||||||
// If the entry date matches the target date, use this entry
|
// If the entry date matches the target date, use this entry
|
||||||
if (entryDate.toDateString() === targetDate.toDateString()) {
|
if (entryDate.toDateString() === targetDate.toDateString()) {
|
||||||
datasetConfig.forEach(config => {
|
datasetConfig.forEach(config => {
|
||||||
histories[config.field].push(getValueFromHistory(history, config.field) ?? null);
|
// Use the correct index for histories
|
||||||
|
histories[config.field][historyDays - 1 - dayAgo] = getValueFromHistory(history, config.field) ?? null;
|
||||||
});
|
});
|
||||||
currentHistoryIndex++;
|
currentHistoryIndex++;
|
||||||
matchedEntry = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no matching entry, fill the current day with null
|
|
||||||
if (!matchedEntry) {
|
|
||||||
datasetConfig.forEach(config => {
|
|
||||||
histories[config.field].push(null);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
labels.push(targetDate);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render the chart with collected data
|
// Render the chart with collected data
|
||||||
|
@ -59,6 +59,34 @@ const datasetConfig: DatasetConfig[] = [
|
|||||||
},
|
},
|
||||||
labelFormatter: (value: number) => `PP ${formatNumberWithCommas(value)}pp`,
|
labelFormatter: (value: number) => `PP ${formatNumberWithCommas(value)}pp`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Ranked Scores",
|
||||||
|
field: "scores.rankedScores",
|
||||||
|
color: "#ffae4d",
|
||||||
|
axisId: "y3",
|
||||||
|
axisConfig: {
|
||||||
|
reverse: false,
|
||||||
|
display: false,
|
||||||
|
displayName: "Ranked Scores",
|
||||||
|
position: "left",
|
||||||
|
},
|
||||||
|
type: "bar",
|
||||||
|
labelFormatter: (value: number) => `Ranked Scores ${formatNumberWithCommas(value)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Unranked Scores",
|
||||||
|
field: "scores.unrankedScores",
|
||||||
|
color: "#616161",
|
||||||
|
axisId: "y3",
|
||||||
|
axisConfig: {
|
||||||
|
reverse: false,
|
||||||
|
display: false,
|
||||||
|
displayName: "Unranked Scores",
|
||||||
|
position: "left",
|
||||||
|
},
|
||||||
|
type: "bar",
|
||||||
|
labelFormatter: (value: number) => `Unranked Scores ${formatNumberWithCommas(value)}`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function PlayerRankingChart({ player }: Props) {
|
export default function PlayerRankingChart({ player }: Props) {
|
||||||
|
Reference in New Issue
Block a user