add daily scores set tracking
This commit is contained in:
parent
5b3218c205
commit
9d38e095fe
@ -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",
|
||||
|
@ -62,13 +62,13 @@ export class Player {
|
||||
public getHistoryPreviousDays(days: number): Record<string, PlayerHistory> {
|
||||
const statisticHistory = this.getStatisticHistory();
|
||||
const history: Record<string, PlayerHistory> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
|
@ -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`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -19,6 +19,7 @@
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"ky": "^1.7.2"
|
||||
"ky": "^1.7.2",
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
|
@ -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...`
|
||||
|
@ -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.
|
||||
*/
|
||||
|
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 */
|
||||
"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 (
|
||||
<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)[]> = {};
|
||||
// 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
|
||||
|
@ -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) {
|
||||
|
Reference in New Issue
Block a user