From 9d38e095fefb7c18672204528259180472861fa2 Mon Sep 17 00:00:00 2001 From: Liam Date: Tue, 15 Oct 2024 04:09:47 +0100 Subject: [PATCH] add daily scores set tracking --- bun.lockb | Bin 308696 -> 309104 bytes projects/backend/src/index.ts | 9 ++- projects/backend/src/model/player.ts | 6 +- .../backend/src/service/player.service.ts | 41 +++++++++++ projects/common/package.json | 3 +- .../types/player/impl/scoresaber-player.ts | 15 +++- .../common/src/types/player/player-history.ts | 15 ++++ .../src/websocket/scoresaber-websocket.ts | 64 ++++++++++++++++++ .../src/components/chart/generic-chart.tsx | 48 +++++++++---- .../player/chart/generic-player-chart.tsx | 23 ++----- .../player/chart/player-ranking-chart.tsx | 28 ++++++++ 11 files changed, 215 insertions(+), 37 deletions(-) create mode 100644 projects/common/src/websocket/scoresaber-websocket.ts diff --git a/bun.lockb b/bun.lockb index 75b1bb1154fd2a6997b444623462a318164e7ee0..06e8afcc62fb64f0296e3c6052225440f6ac6573 100755 GIT binary patch delta 44029 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 Zka8d-pv@r;eRT0>+Wq(QD5D~c{sj+ISGoWI delta 43435 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 BrJ?`; 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) {