diff --git a/bun.lockb b/bun.lockb index 75b1bb1..06e8afc 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/projects/backend/src/index.ts b/projects/backend/src/index.ts index a158234..c02cc09 100644 --- a/projects/backend/src/index.ts +++ b/projects/backend/src/index.ts @@ -18,6 +18,7 @@ import { cron } from "@elysiajs/cron"; import { PlayerDocument, PlayerModel } from "./model/player"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import { delay } from "@ssr/common/utils/utils"; +import { connectScoreSaberWebSocket } from "@ssr/common/websocket/scoresaber-websocket"; // Load .env file dotenv.config({ @@ -28,8 +29,14 @@ dotenv.config({ await mongoose.connect(Config.mongoUri!); // Connect to MongoDB setLogLevel("DEBUG"); -export const app = new Elysia(); +connectScoreSaberWebSocket({ + onScore: async score => { + await PlayerService.trackScore(score); + }, +}); + +export const app = new Elysia(); app.use( cron({ name: "player-statistics-tracker-cron", diff --git a/projects/backend/src/model/player.ts b/projects/backend/src/model/player.ts index a4dc7cc..52a1162 100644 --- a/projects/backend/src/model/player.ts +++ b/projects/backend/src/model/player.ts @@ -62,13 +62,13 @@ export class Player { public getHistoryPreviousDays(days: number): Record { const statisticHistory = this.getStatisticHistory(); const history: Record = {}; + for (let i = 0; i < days; i++) { const date = formatDateMinimal(getMidnightAlignedDate(getDaysAgoDate(i))); const playerHistory = statisticHistory[date]; - if (playerHistory === undefined || Object.keys(playerHistory).length === 0) { - continue; + if (playerHistory !== undefined && Object.keys(playerHistory).length > 0) { + history[date] = playerHistory; } - history[date] = playerHistory; } return history; } diff --git a/projects/backend/src/service/player.service.ts b/projects/backend/src/service/player.service.ts index a1200ba..94abdc2 100644 --- a/projects/backend/src/service/player.service.ts +++ b/projects/backend/src/service/player.service.ts @@ -4,6 +4,7 @@ import { getDaysAgoDate, getMidnightAlignedDate } from "@ssr/common/utils/time-u import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token"; import { InternalServerError } from "../error/internal-server-error"; +import ScoreSaberPlayerScoreToken from "@ssr/common/types/token/scoresaber/score-saber-player-score-token"; export class PlayerService { /** @@ -113,4 +114,44 @@ export class PlayerService { 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` + ); + } } diff --git a/projects/common/package.json b/projects/common/package.json index fb71653..043a398 100644 --- a/projects/common/package.json +++ b/projects/common/package.json @@ -19,6 +19,7 @@ "typescript": "^5" }, "dependencies": { - "ky": "^1.7.2" + "ky": "^1.7.2", + "ws": "^8.18.0" } } diff --git a/projects/common/src/types/player/impl/scoresaber-player.ts b/projects/common/src/types/player/impl/scoresaber-player.ts index cf44f29..fe04345 100644 --- a/projects/common/src/types/player/impl/scoresaber-player.ts +++ b/projects/common/src/types/player/impl/scoresaber-player.ts @@ -110,6 +110,13 @@ export async function getScoreSaberPlayerFromToken( if (history) { // Use the latest data for today history[todayDate] = { + ...{ + scores: { + rankedScores: 0, + unrankedScores: 0, + }, + }, + ...history[todayDate], rank: token.rank, countryRank: token.countryRank, pp: token.pp, @@ -133,15 +140,17 @@ export async function getScoreSaberPlayerFromToken( for (let i = playerRankHistory.length - 1; i >= 0; i--) { const rank = playerRankHistory[i]; 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; - statisticHistory[formatDateMinimal(date)] = { + statisticHistory[dateKey] = { rank: rank, }; } } + if (missingDays > 0 && missingDays != playerRankHistory.length) { console.log( `Player has ${missingDays} missing day${missingDays > 1 ? "s" : ""}, filling in with fallback history...` diff --git a/projects/common/src/types/player/player-history.ts b/projects/common/src/types/player/player-history.ts index 5be6d1a..a08cc1f 100644 --- a/projects/common/src/types/player/player-history.ts +++ b/projects/common/src/types/player/player-history.ts @@ -14,6 +14,21 @@ export interface PlayerHistory { */ 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. */ diff --git a/projects/common/src/websocket/scoresaber-websocket.ts b/projects/common/src/websocket/scoresaber-websocket.ts new file mode 100644 index 0000000..8abf283 --- /dev/null +++ b/projects/common/src/websocket/scoresaber-websocket.ts @@ -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"); +} diff --git a/projects/website/src/components/chart/generic-chart.tsx b/projects/website/src/components/chart/generic-chart.tsx index 97ab7b9..c545180 100644 --- a/projects/website/src/components/chart/generic-chart.tsx +++ b/projects/website/src/components/chart/generic-chart.tsx @@ -1,14 +1,25 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ "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 { 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 DatasetDisplayType = "line" | "bar"; export type Axis = { id?: string; @@ -33,6 +44,7 @@ export type Dataset = { lineTension: number; spanGaps: boolean; yAxisID: string; + type?: DatasetDisplayType; }; export type DatasetConfig = { @@ -48,6 +60,7 @@ export type DatasetConfig = { position: AxisPosition; valueFormatter?: (value: number) => string; // Added precision option here }; + type?: DatasetDisplayType; labelFormatter: (value: number) => string; }; @@ -80,7 +93,13 @@ const generateAxis = ( 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, data, borderColor, @@ -88,6 +107,10 @@ const generateDataset = (label: string, data: (number | null)[], borderColor: st lineTension: 0.5, spanGaps: false, yAxisID, + type, + ...(type === "bar" && { + backgroundColor: borderColor, + }), }); export default function GenericChart({ labels, datasetConfig, histories }: ChartProps) { @@ -130,7 +153,7 @@ export default function GenericChart({ labels, datasetConfig, histories }: Chart 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; @@ -149,12 +172,10 @@ export default function GenericChart({ labels, datasetConfig, histories }: Chart callbacks: { title(context: any) { const date = labels[context[0].dataIndex]; - const currentDate = new Date(); - const differenceInTime = currentDate.getTime() - new Date(date).getTime(); - const differenceInDays = Math.ceil(differenceInTime / (1000 * 3600 * 24)) - 1; + const differenceInDays = getDaysAgo(date); let formattedDate: string; if (differenceInDays === 0) { - formattedDate = "Today"; + formattedDate = "Now"; } else if (differenceInDays === 1) { formattedDate = "Yesterday"; } else { @@ -174,17 +195,18 @@ export default function GenericChart({ labels, datasetConfig, histories }: Chart }; const formattedLabels = labels.map(date => { - if (formatDateMinimal(getDaysAgoDate(0)) === formatDateMinimal(date)) { + const formattedDate = formatDateMinimal(date); + if (formatDateMinimal(getDaysAgoDate(0)) === formattedDate) { return "Now"; } - if (formatDateMinimal(getDaysAgoDate(1)) === formatDateMinimal(date)) { + if (formatDateMinimal(getDaysAgoDate(1)) === formattedDate) { return "Yesterday"; } - return formatDateMinimal(date); + return formattedDate; }); - const data = { labels: formattedLabels, datasets }; + const data: any = { labels: formattedLabels, datasets }; return (
diff --git a/projects/website/src/components/player/chart/generic-player-chart.tsx b/projects/website/src/components/player/chart/generic-player-chart.tsx index 246a061..d02f3f4 100644 --- a/projects/website/src/components/player/chart/generic-player-chart.tsx +++ b/projects/website/src/components/player/chart/generic-player-chart.tsx @@ -29,13 +29,14 @@ export default function GenericPlayerChart({ player, datasetConfig }: Props) { } const histories: Record = {}; - // Initialize histories for each dataset + const historyDays = 50; + + // Initialize histories for each dataset with null values for all days datasetConfig.forEach(config => { - histories[config.field] = []; + histories[config.field] = Array(historyDays).fill(null); }); const labels: Date[] = []; - const historyDays = 50; // Sort the statistic entries by date 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--) { const targetDate = new Date(); targetDate.setDate(today.getDate() - dayAgo); - - // Find if there's a matching entry for this date - let matchedEntry = false; + labels.push(targetDate); // Push the target date to labels // Check if currentHistoryIndex is within bounds of statisticEntries 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 (entryDate.toDateString() === targetDate.toDateString()) { 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++; - 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 diff --git a/projects/website/src/components/player/chart/player-ranking-chart.tsx b/projects/website/src/components/player/chart/player-ranking-chart.tsx index c9719a9..280711f 100644 --- a/projects/website/src/components/player/chart/player-ranking-chart.tsx +++ b/projects/website/src/components/player/chart/player-ranking-chart.tsx @@ -59,6 +59,34 @@ const datasetConfig: DatasetConfig[] = [ }, 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) {