add daily scores set tracking
Some checks are pending
Deploy Website / deploy (push) Waiting to run
Deploy Backend / deploy (push) Successful in 3m51s

This commit is contained in:
Lee 2024-10-15 04:09:47 +01:00
parent 5b3218c205
commit 9d38e095fe
11 changed files with 215 additions and 37 deletions

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.
*/ */

@ -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) {