add beatleader data tracking!!!!!!!!!!!!!
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 44s
Deploy Website / docker (ubuntu-latest) (push) Failing after 38s

This commit is contained in:
Lee 2024-10-22 15:59:41 +01:00
parent 074d4de123
commit fa2ba83c7a
36 changed files with 767 additions and 173 deletions

BIN
bun.lockb

Binary file not shown.

@ -13,6 +13,7 @@
"author": "fascinated7", "author": "fascinated7",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"concurrently": "^9.0.1" "concurrently": "^9.0.1",
"cross-env": "^7.0.3"
} }
} }

@ -1,6 +1,6 @@
import { Client, MetadataStorage } from "discordx"; import { Client, MetadataStorage } from "discordx";
import { Config } from "@ssr/common/config";
import { ActivityType, EmbedBuilder } from "discord.js"; import { ActivityType, EmbedBuilder } from "discord.js";
import { Config } from "@ssr/common/config";
export enum DiscordChannels { export enum DiscordChannels {
trackedPlayerLogs = "1295985197262569512", trackedPlayerLogs = "1295985197262569512",
@ -12,6 +12,7 @@ const DiscordBot = new Client({
intents: [], intents: [],
presence: { presence: {
status: "online", status: "online",
activities: [ activities: [
{ {
name: "scores...", name: "scores...",

@ -1,5 +1,6 @@
import { SSRCache } from "@ssr/common/cache"; import { SSRCache } from "@ssr/common/cache";
import { InternalServerError } from "../error/internal-server-error"; import { InternalServerError } from "../error/internal-server-error";
import { isProduction } from "@ssr/common/utils/utils";
/** /**
* Fetches data with caching. * Fetches data with caching.
@ -13,6 +14,10 @@ export async function fetchWithCache<T>(
cacheKey: string, cacheKey: string,
fetchFn: () => Promise<T | undefined> fetchFn: () => Promise<T | undefined>
): Promise<T | undefined> { ): Promise<T | undefined> {
if (!isProduction()) {
return await fetchFn();
}
if (cache == undefined) { if (cache == undefined) {
throw new InternalServerError(`Cache is not defined`); throw new InternalServerError(`Cache is not defined`);
} }

@ -14,16 +14,16 @@ import { PlayerService } from "./service/player.service";
import { cron } from "@elysiajs/cron"; import { cron } from "@elysiajs/cron";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import { delay, isProduction } from "@ssr/common/utils/utils"; import { delay, isProduction } from "@ssr/common/utils/utils";
import { connectScoreSaberWebSocket } from "@ssr/common/websocket/scoresaber-websocket";
import ImageController from "./controller/image.controller"; import ImageController from "./controller/image.controller";
import { ScoreService } from "./service/score.service"; import { ScoreService } from "./service/score.service";
import { Config } from "@ssr/common/config"; import { Config } from "@ssr/common/config";
import { PlayerDocument, PlayerModel } from "@ssr/common/model/player"; import { PlayerDocument, PlayerModel } from "@ssr/common/model/player";
import ScoresController from "./controller/scores.controller"; import ScoresController from "./controller/scores.controller";
import LeaderboardController from "./controller/leaderboard.controller"; import LeaderboardController from "./controller/leaderboard.controller";
import { DiscordChannels, initDiscordBot, logToChannel } from "./bot/bot";
import { EmbedBuilder } from "discord.js";
import { getAppVersion } from "./common/app.util"; import { getAppVersion } from "./common/app.util";
import { connectScoresaberWebsocket } from "@ssr/common/websocket/scoresaber-websocket";
import { connectBeatLeaderWebsocket } from "@ssr/common/websocket/beatleader-websocket";
import { initDiscordBot } from "./bot/bot";
// Load .env file // Load .env file
dotenv.config({ dotenv.config({
@ -35,16 +35,15 @@ dotenv.config({
await mongoose.connect(Config.mongoUri!); // Connect to MongoDB await mongoose.connect(Config.mongoUri!); // Connect to MongoDB
setLogLevel("DEBUG"); setLogLevel("DEBUG");
connectScoreSaberWebSocket({ connectScoresaberWebsocket({
onScore: async playerScore => { onScore: async score => {
await PlayerService.trackScore(playerScore); await ScoreService.trackScoreSaberScore(score);
await ScoreService.notifyNumberOne(playerScore); await ScoreService.notifyNumberOne(score);
}, },
onDisconnect: async error => { });
await logToChannel( connectBeatLeaderWebsocket({
DiscordChannels.backendLogs, onScore: async score => {
new EmbedBuilder().setDescription(`ScoreSaber websocket disconnected: ${error}`) await ScoreService.trackBeatLeaderScore(score);
);
}, },
}); });

@ -4,7 +4,6 @@ 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";
import { formatPp } from "@ssr/common/utils/number-utils"; import { formatPp } from "@ssr/common/utils/number-utils";
import { getPageFromRank, isProduction } from "@ssr/common/utils/utils"; import { getPageFromRank, isProduction } from "@ssr/common/utils/utils";
import { DiscordChannels, logToChannel } from "../bot/bot"; import { DiscordChannels, logToChannel } from "../bot/bot";
@ -171,46 +170,6 @@ 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 playerName = score.leaderboardPlayerInfo.name;
const player: PlayerDocument | null = await PlayerModel.findById(playerId);
// Player is not tracked, so ignore the score.
if (player == undefined) {
return;
}
const today = new Date();
const history = player.getHistoryByDate(today);
const scores = history.scores || {
rankedScores: 0,
unrankedScores: 0,
};
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 "${playerName}"(${playerId}), scores today: ${scores.rankedScores} ranked, ${scores.unrankedScores} unranked`
);
}
/** /**
* Gets the players around a player. * Gets the players around a player.
* *

@ -23,6 +23,9 @@ import { EmbedBuilder } from "discord.js";
import { Config } from "@ssr/common/config"; import { Config } from "@ssr/common/config";
import { SSRCache } from "@ssr/common/cache"; import { SSRCache } from "@ssr/common/cache";
import { fetchWithCache } from "../common/cache.util"; import { fetchWithCache } from "../common/cache.util";
import { PlayerDocument, PlayerModel } from "@ssr/common/model/player";
import { BeatLeaderScoreToken } from "@ssr/common/types/token/beatleader/beatleader-score-token";
import { AdditionalScoreData, AdditionalScoreDataModel } from "@ssr/common/model/additional-score-data";
const playerScoresCache = new SSRCache({ const playerScoresCache = new SSRCache({
ttl: 1000 * 60, // 1 minute ttl: 1000 * 60, // 1 minute
@ -111,6 +114,107 @@ export class ScoreService {
); );
} }
/**
* Tracks ScoreSaber score.
*
* @param score the score to track
* @param leaderboard the leaderboard to track
*/
public static async trackScoreSaberScore({ score, leaderboard }: ScoreSaberPlayerScoreToken) {
const playerId = score.leaderboardPlayerInfo.id;
const playerName = score.leaderboardPlayerInfo.name;
const player: PlayerDocument | null = await PlayerModel.findById(playerId);
// Player is not tracked, so ignore the score.
if (player == undefined) {
return;
}
const today = new Date();
const history = player.getHistoryByDate(today);
const scores = history.scores || {
rankedScores: 0,
unrankedScores: 0,
};
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 "${playerName}"(${playerId}), scores today: ${scores.rankedScores} ranked, ${scores.unrankedScores} unranked`
);
}
/**
* Tracks BeatLeader score.
*
* @param score the score to track
*/
public static async trackBeatLeaderScore(score: BeatLeaderScoreToken) {
const { playerId, player: scorePlayer, leaderboard } = score;
const player: PlayerDocument | null = await PlayerModel.findById(playerId);
// Player is not tracked, so ignore the score.
if (player == undefined) {
return;
}
const difficulty = leaderboard.difficulty;
const difficultyKey = `${difficulty.difficultyName.replace("Plus", "+")}-${difficulty.modeName}`;
await AdditionalScoreDataModel.create({
playerId: playerId,
songHash: leaderboard.song.hash,
songDifficulty: difficultyKey,
songScore: score.baseScore,
bombCuts: score.bombCuts,
wallsHit: score.wallsHit,
pauses: score.pauses,
fcAccuracy: score.fcAccuracy * 100,
handAccuracy: {
left: score.accLeft,
right: score.accRight,
},
} as AdditionalScoreData);
console.log(
`Tracked additional score data for "${scorePlayer.name}"(${playerId}), difficulty: ${difficultyKey}, score: ${score.baseScore}`
);
}
/**
* Gets the additional score data for a player's score.
*
* @param playerId the id of the player
* @param songHash the hash of the map
* @param songDifficulty the difficulty of the map
* @param songScore the score of the play
* @private
*/
private static async getAdditionalScoreData(
playerId: string,
songHash: string,
songDifficulty: string,
songScore: number
): Promise<AdditionalScoreData | undefined> {
const additionalData = await AdditionalScoreDataModel.findOne({
playerId: playerId,
songHash: songHash,
songDifficulty: songDifficulty,
songScore: songScore,
});
if (!additionalData) {
return undefined;
}
return additionalData.toObject();
}
/** /**
* Gets scores for a player. * Gets scores for a player.
* *
@ -128,12 +232,12 @@ export class ScoreService {
sort: string, sort: string,
search?: string search?: string
): Promise<PlayerScoresResponse<unknown, unknown> | undefined> { ): Promise<PlayerScoresResponse<unknown, unknown> | undefined> {
console.log("hi");
return fetchWithCache( return fetchWithCache(
playerScoresCache, playerScoresCache,
`player-scores-${leaderboardName}-${id}-${page}-${sort}-${search}`, `player-scores-${leaderboardName}-${id}-${page}-${sort}-${search}`,
async () => { async () => {
const scores: PlayerScore<unknown, unknown>[] | undefined = []; const scores: PlayerScore<unknown, unknown>[] | undefined = [];
let beatSaverMap: BeatSaverMap | undefined;
let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values
switch (leaderboardName) { switch (leaderboardName) {
@ -164,12 +268,22 @@ export class ScoreService {
if (tokenLeaderboard == undefined) { if (tokenLeaderboard == undefined) {
continue; continue;
} }
beatSaverMap = await BeatSaverService.getMap(tokenLeaderboard.songHash);
const additionalData = await this.getAdditionalScoreData(
id,
tokenLeaderboard.songHash,
`${tokenLeaderboard.difficulty.difficulty}-${tokenLeaderboard.difficulty.gameMode}`,
score.score
);
console.log("additionalData", additionalData);
if (additionalData !== undefined) {
score.additionalData = additionalData;
}
scores.push({ scores.push({
score: score, score: score,
leaderboard: tokenLeaderboard, leaderboard: tokenLeaderboard,
beatSaver: beatSaverMap, beatSaver: await BeatSaverService.getMap(tokenLeaderboard.songHash),
}); });
} }
break; break;

@ -41,7 +41,7 @@ export function getScoreSaberLeaderboardFromToken(token: ScoreSaberLeaderboardTo
const difficulty: LeaderboardDifficulty = { const difficulty: LeaderboardDifficulty = {
leaderboardId: token.difficulty.leaderboardId, leaderboardId: token.difficulty.leaderboardId,
difficulty: getDifficultyFromScoreSaberDifficulty(token.difficulty.difficulty), difficulty: getDifficultyFromScoreSaberDifficulty(token.difficulty.difficulty),
gameMode: token.difficulty.gameMode, gameMode: token.difficulty.gameMode.replace("Solo", ""),
difficultyRaw: token.difficulty.difficultyRaw, difficultyRaw: token.difficulty.difficultyRaw,
}; };
@ -66,7 +66,7 @@ export function getScoreSaberLeaderboardFromToken(token: ScoreSaberLeaderboardTo
return { return {
leaderboardId: difficulty.leaderboardId, leaderboardId: difficulty.leaderboardId,
difficulty: getDifficultyFromScoreSaberDifficulty(difficulty.difficulty), difficulty: getDifficultyFromScoreSaberDifficulty(difficulty.difficulty),
gameMode: difficulty.gameMode, gameMode: difficulty.gameMode.replace("Solo", ""),
difficultyRaw: difficulty.difficultyRaw, difficultyRaw: difficulty.difficultyRaw,
}; };
}) })

@ -0,0 +1,95 @@
import { getModelForClass, modelOptions, prop, ReturnModelType, Severity } from "@typegoose/typegoose";
import { Document } from "mongoose";
/**
* The model for a BeatSaver map.
*/
@modelOptions({
options: { allowMixed: Severity.ALLOW },
schemaOptions: {
collection: "additional-score-data",
toObject: {
virtuals: true,
transform: function (_, ret) {
delete ret._id;
delete ret.playerId;
delete ret.songHash;
delete ret.songDifficulty;
delete ret.songScore;
delete ret.__v;
return ret;
},
},
},
})
export class AdditionalScoreData {
/**
* The of the player who set the score.
*/
@prop({ required: true, index: true })
public playerId!: string;
/**
* The hash of the song.
*/
@prop({ required: true, index: true })
public songHash!: string;
/**
* The difficulty the score was set on.
*/
@prop({ required: true, index: true })
public songDifficulty!: string;
/**
* The score of the play.
*/
@prop({ required: true, index: true })
public songScore!: number;
/**
* The amount of times a bomb was hit.
*/
@prop({ required: false })
public bombCuts!: number;
/**
* The amount of walls hit in the play.
*/
@prop({ required: false })
public wallsHit!: number;
/**
* The amount of pauses in the play.
*/
@prop({ required: false })
public pauses!: number;
/**
* The hand accuracy for each hand.
* @private
*/
@prop({ required: false })
public handAccuracy!: {
/**
* The left hand accuracy.
*/
left: number;
/**
* The right hand accuracy.
*/
right: number;
};
/**
* The full combo accuracy of the play.
*/
@prop({ required: true })
public fcAccuracy!: number;
}
export type AdditionalScoreDataDocument = AdditionalScoreData & Document;
export const AdditionalScoreDataModel: ReturnModelType<typeof AdditionalScoreData> =
getModelForClass(AdditionalScoreData);

@ -35,7 +35,7 @@ export interface PlayerHistory {
}; };
/** /**
* The amount of scores set for this day. * The player's scores stats.
*/ */
scores?: { scores?: {
/** /**
@ -60,7 +60,7 @@ export interface PlayerHistory {
}; };
/** /**
* The player's accuracy. * The player's accuracy stats.
*/ */
accuracy?: { accuracy?: {
/** /**

@ -1,5 +1,6 @@
import { Modifier } from "./modifier"; import { Modifier } from "./modifier";
import { Leaderboards } from "../leaderboard"; import { Leaderboards } from "../leaderboard";
import { AdditionalScoreData } from "../model/additional-score-data";
export default interface Score { export default interface Score {
/** /**
@ -53,6 +54,11 @@ export default interface Score {
*/ */
readonly fullCombo: boolean; readonly fullCombo: boolean;
/**
* The additional data for the score.
*/
additionalData?: AdditionalScoreData;
/** /**
* The time the score was set. * The time the score was set.
* @private * @private

@ -0,0 +1,30 @@
import { BeatLeaderModifierToken } from "./beatleader-modifiers-token";
import { BeatLeaderModifierRatingToken } from "./beatleader-modifier-rating-token";
export type BeatLeaderDifficultyToken = {
id: number;
value: number;
mode: number;
difficultyName: string;
modeName: string;
status: number;
modifierValues: BeatLeaderModifierToken;
modifiersRating: BeatLeaderModifierRatingToken;
nominatedTime: number;
qualifiedTime: number;
rankedTime: number;
stars: number;
predictedAcc: number;
passRating: number;
accRating: number;
techRating: number;
type: number;
njs: number;
nps: number;
notes: number;
bombs: number;
walls: number;
maxScore: number;
duration: number;
requirements: number;
};

@ -0,0 +1,16 @@
import { BeatLeaderSongToken } from "./beatleader-song-token";
import { BeatLeaderDifficultyToken } from "./beatleader-difficulty-token";
export type BeatLeaderLeaderboardToken = {
id: string;
song: BeatLeaderSongToken;
difficulty: BeatLeaderDifficultyToken;
scores: null; // ??
changes: null; // ??
qualification: null; // ??
reweight: null; // ??
leaderboardGroup: null; // ??
plays: number;
clan: null; // ??
clanRankingContested: boolean;
};

@ -0,0 +1,18 @@
export type BeatLeaderModifierRatingToken = {
id: number;
fsPredictedAcc: number;
fsPassRating: number;
fsAccRating: number;
fsTechRating: number;
fsStars: number;
ssPredictedAcc: number;
ssPassRating: number;
ssAccRating: number;
ssTechRating: number;
ssStars: number;
sfPredictedAcc: number;
sfPassRating: number;
sfAccRating: number;
sfTechRating: number;
sfStars: number;
};

@ -0,0 +1,16 @@
export type BeatLeaderModifierToken = {
modifierId: number;
da: number;
fs: number;
sf: number;
ss: number;
gn: number;
na: number;
nb: number;
nf: number;
no: number;
pm: number;
sc: number;
sa: number;
op: number;
};

@ -0,0 +1,10 @@
export type BeatLeaderPlayerToken = {
id: string;
country: string;
avatar: string;
pp: number;
rank: number;
countryRank: number;
name: string;
// todo: finish this
};

@ -0,0 +1,19 @@
export type BeatLeaderScoreImprovementToken = {
id: number;
timeset: number;
score: number;
accuracy: number;
pp: number;
bonusPp: number;
rank: number;
accRight: number;
accLeft: number;
averageRankedAccuracy: number;
totalPp: number;
totalRank: number;
badCuts: number;
missedNotes: number;
bombCuts: number;
wallsHit: number;
pauses: number;
};

@ -0,0 +1,8 @@
export type BeatLeaderScoreOffsetsToken = {
id: number;
frames: number;
notes: number;
walls: number;
heights: number;
pauses: number;
};

@ -0,0 +1,52 @@
import { BeatLeaderLeaderboardToken } from "./beatleader-leaderboard-token";
import { BeatLeaderScoreImprovementToken } from "./beatleader-score-improvement-token";
import { BeatLeaderScoreOffsetsToken } from "./beatleader-score-offsets-token";
import { BeatLeaderPlayerToken } from "./beatleader-player-token";
export type BeatLeaderScoreToken = {
myScore: null; // ??
validContexts: number;
leaderboard: BeatLeaderLeaderboardToken;
contextExtensions: null; // ??
accLeft: number;
accRight: number;
id: number;
baseScore: number;
modifiedScore: number;
accuracy: number;
playerId: string;
pp: number;
bonusPp: number;
passPP: number;
accPP: number;
techPP: number;
rank: number;
country: string;
fcAccuracy: number;
fcPp: number;
weight: number;
replay: string;
modifiers: string;
badCuts: number;
missedNotes: number;
bombCuts: number;
wallsHit: number;
pauses: number;
fullCombo: boolean;
platform: string;
maxCombo: number;
maxStreak: number;
hmd: number;
controller: number;
leaderboardId: string;
timeset: string;
timepost: number;
replaysWatched: number;
playCount: number;
priority: number;
player: BeatLeaderPlayerToken; // ??
scoreImprovement: BeatLeaderScoreImprovementToken;
rankVoting: null; // ??
metadata: null; // ??
offsets: BeatLeaderScoreOffsetsToken;
};

@ -0,0 +1,16 @@
export type BeatLeaderSongToken = {
id: string;
hash: string;
name: string;
subName: string;
author: string;
mapperId: string;
coverImage: string;
fullCoverImage: string;
downloadUrl: string;
bpm: number;
duration: number;
tags: string;
uploadTime: number;
difficulties: null; // ??
};

@ -0,0 +1,30 @@
import { connectWebSocket, WebsocketCallbacks } from "./websocket";
import { BeatLeaderScoreToken } from "../types/token/beatleader/beatleader-score-token";
type BeatLeaderWebsocket = {
/**
* Invoked when a score message is received.
*
* @param score the received score data.
*/
onScore?: (score: BeatLeaderScoreToken) => void;
} & WebsocketCallbacks;
/**
* Connects to the BeatLeader websocket and handles incoming messages.
*
* @param onMessage the onMessage callback
* @param onScore the onScore callback
* @param onDisconnect the onDisconnect callback
*/
export function connectBeatLeaderWebsocket({ onMessage, onScore, onDisconnect }: BeatLeaderWebsocket) {
return connectWebSocket({
name: "BeatLeader",
url: "wss://sockets.api.beatleader.xyz/scores",
onMessage: (message: unknown) => {
onScore && onScore(message as BeatLeaderScoreToken);
onMessage && onMessage(message);
},
onDisconnect,
});
}

@ -1,74 +1,38 @@
import WebSocket from "ws"; import { connectWebSocket, WebsocketCallbacks } from "./websocket";
import ScoreSaberPlayerScoreToken from "../types/token/scoresaber/score-saber-player-score-token"; import ScoreSaberPlayerScoreToken from "../types/token/scoresaber/score-saber-player-score-token";
import { ScoreSaberWebsocketMessageToken } from "../types/token/scoresaber/websocket/scoresaber-websocket-message";
type ScoresaberSocket = { 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. * Invoked when a score message is received.
* *
* @param score the received score data. * @param score the received score data.
*/ */
onScore?: (score: ScoreSaberPlayerScoreToken) => void; onScore?: (score: ScoreSaberPlayerScoreToken) => void;
} & WebsocketCallbacks;
/** /**
* Invoked when the connection is closed. * Connects to the Scoresaber websocket and handles incoming messages.
* *
* @param error the error that caused the connection to close * @param onMessage the onMessage callback
* @param onScore the onScore callback
* @param onDisconnect the onDisconnect callback
*/ */
onDisconnect?: (error?: WebSocket.ErrorEvent | WebSocket.CloseEvent) => void; export function connectScoresaberWebsocket({ onMessage, onScore, onDisconnect }: ScoresaberWebsocket) {
}; return connectWebSocket({
name: "Scoresaber",
/** url: "wss://scoresaber.com/ws",
* Connects to the ScoreSaber websocket and handles incoming messages. onMessage: (message: unknown) => {
*/ const command = message as ScoreSaberWebsocketMessageToken;
export function connectScoreSaberWebSocket({ onMessage, onScore, onDisconnect }: ScoresaberSocket) { if (typeof command !== "object" || command === null) {
let websocket: WebSocket | null = null; return;
function connectWs() {
websocket = new WebSocket("wss://scoresaber.com/ws");
websocket.onopen = () => {
console.log("Connected to the ScoreSaber WebSocket!");
};
websocket.onerror = error => {
console.error("WebSocket Error:", error);
if (websocket) {
websocket.close(); // Close the connection on error
} }
onDisconnect && onDisconnect(error);
};
websocket.onclose = event => {
console.log("Lost connection to the ScoreSaber WebSocket. Attempting to reconnect...");
onDisconnect && onDisconnect(event);
setTimeout(connectWs, 5000); // Reconnect after 5 seconds
};
websocket.onmessage = messageEvent => {
if (typeof messageEvent.data !== "string") return;
try {
const command = JSON.parse(messageEvent.data);
if (command.commandName === "score") { if (command.commandName === "score") {
onScore && onScore(command.commandData as ScoreSaberPlayerScoreToken); onScore && onScore(command.commandData as ScoreSaberPlayerScoreToken);
} else { } else {
onMessage && onMessage(command); onMessage && onMessage(command);
} }
} catch (err) { },
console.warn("Received invalid message:", messageEvent.data); onDisconnect,
} });
};
}
connectWs(); // Initiate the first connection
} }

@ -0,0 +1,89 @@
import WebSocket from "ws";
import { DiscordChannels, logToChannel } from "backend/src/bot/bot";
import { EmbedBuilder } from "discord.js";
export type WebsocketCallbacks = {
/**
* Invoked when a general message is received.
*
* @param message the received message.
*/
onMessage?: (message: unknown) => void;
/**
* Invoked when the connection is closed.
*
* @param error the error that caused the connection to close
*/
onDisconnect?: (error?: WebSocket.ErrorEvent | WebSocket.CloseEvent) => void;
};
type Websocket = {
/**
* The name of the service we're connecting to.
*/
name: string;
/**
* The url of the service we're connecting to.
*/
url: string;
} & WebsocketCallbacks;
/**
* Connects to the ScoreSaber websocket and handles incoming messages.
*/
export function connectWebSocket({ name, url, onMessage, onDisconnect }: Websocket) {
let websocket: WebSocket | null = null;
/**
* Logs to the backend logs channel.
*
* @param error the error to log
*/
async function log(error: WebSocket.ErrorEvent | WebSocket.CloseEvent) {
await logToChannel(
DiscordChannels.backendLogs,
new EmbedBuilder().setDescription(`${name} websocket disconnected: ${JSON.stringify(error)}`)
);
}
function connectWs() {
websocket = new WebSocket(url);
websocket.onopen = () => {
console.log(`Connected to the ${name} WebSocket!`);
};
websocket.onerror = event => {
console.error("WebSocket Error:", event);
if (websocket) {
websocket.close(); // Close the connection on error
}
onDisconnect && onDisconnect(event);
log(event);
};
websocket.onclose = event => {
console.log(`Lost connection to the ${name} WebSocket. Attempting to reconnect...`);
onDisconnect && onDisconnect(event);
log(event);
setTimeout(connectWs, 5000); // Reconnect after 5 seconds
};
websocket.onmessage = messageEvent => {
if (typeof messageEvent.data !== "string") return;
try {
const command = JSON.parse(messageEvent.data);
onMessage && onMessage(command);
} catch (err) {
console.warn(`Received invalid json message on ${name}:`, messageEvent.data);
}
};
}
connectWs(); // Initiate the first connection
}

@ -4,6 +4,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbo", "dev": "next dev --turbo",
"dev-debug": "cross-env NODE_OPTIONS='--inspect' next dev --turbo",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint" "lint": "next lint"

@ -10,6 +10,7 @@ import { useEffect, useState } from "react";
import { fetchLeaderboard } from "@ssr/common/utils/leaderboard.util"; import { fetchLeaderboard } from "@ssr/common/utils/leaderboard.util";
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response"; import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
import LeaderboardPpChart from "@/components/leaderboard/leaderboard-pp-chart"; import LeaderboardPpChart from "@/components/leaderboard/leaderboard-pp-chart";
import Card from "@/components/card";
type LeaderboardDataProps = { type LeaderboardDataProps = {
/** /**
@ -48,6 +49,7 @@ export function LeaderboardData({ initialLeaderboard, initialScores, initialPage
const leaderboard = currentLeaderboard.leaderboard; const leaderboard = currentLeaderboard.leaderboard;
return ( return (
<main className="flex flex-col-reverse xl:flex-row w-full gap-2"> <main className="flex flex-col-reverse xl:flex-row w-full gap-2">
<Card className="flex gap-2 w-full relative">
<LeaderboardScores <LeaderboardScores
leaderboard={leaderboard} leaderboard={leaderboard}
initialScores={initialScores} initialScores={initialScores}
@ -56,6 +58,7 @@ export function LeaderboardData({ initialLeaderboard, initialScores, initialPage
showDifficulties showDifficulties
isLeaderboardPage isLeaderboardPage
/> />
</Card>
<div className="flex flex-col gap-2 w-full xl:w-[550px]"> <div className="flex flex-col gap-2 w-full xl:w-[550px]">
<LeaderboardInfo leaderboard={leaderboard} beatSaverMap={currentLeaderboard.beatsaver} /> <LeaderboardInfo leaderboard={leaderboard} beatSaverMap={currentLeaderboard.beatsaver} />
{leaderboard.stars > 0 && <LeaderboardPpChart leaderboard={leaderboard} />} {leaderboard.stars > 0 && <LeaderboardPpChart leaderboard={leaderboard} />}

@ -4,12 +4,10 @@ import useWindowDimensions from "@/hooks/use-window-dimensions";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { motion, useAnimation } from "framer-motion"; import { motion, useAnimation } from "framer-motion";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import Card from "../card";
import Pagination from "../input/pagination"; import Pagination from "../input/pagination";
import LeaderboardScore from "./leaderboard-score"; import LeaderboardScore from "./leaderboard-score";
import { scoreAnimation } from "@/components/score/score-animation"; import { scoreAnimation } from "@/components/score/score-animation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { clsx } from "clsx";
import { getDifficultyFromRawDifficulty } from "@/common/song-utils"; import { getDifficultyFromRawDifficulty } from "@/common/song-utils";
import { fetchLeaderboardScores } from "@ssr/common/utils/score-utils"; import { fetchLeaderboardScores } from "@ssr/common/utils/score-utils";
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score"; import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
@ -17,6 +15,7 @@ import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leade
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response"; import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
import useDatabase from "@/hooks/use-database"; import useDatabase from "@/hooks/use-database";
import { useLiveQuery } from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import LeaderboardScoresSkeleton from "@/components/leaderboard/skeleton/leaderboard-scores-skeleton";
type LeaderboardScoresProps = { type LeaderboardScoresProps = {
initialPage?: number; initialPage?: number;
@ -126,11 +125,11 @@ export default function LeaderboardScores({
}, [selectedLeaderboardId, currentPage, disableUrlChanging]); }, [selectedLeaderboardId, currentPage, disableUrlChanging]);
if (currentScores === undefined) { if (currentScores === undefined) {
return undefined; return <LeaderboardScoresSkeleton />;
} }
return ( return (
<Card className={clsx("flex gap-2 w-full relative", !isLeaderboardPage && "border border-input")}> <>
{/* Where to scroll to when new scores are loaded */} {/* Where to scroll to when new scores are loaded */}
<div ref={topOfScoresRef} className="absolute" /> <div ref={topOfScoresRef} className="absolute" />
@ -207,6 +206,6 @@ export default function LeaderboardScores({
setShouldFetch(true); setShouldFetch(true);
}} }}
/> />
</Card> </>
); );
} }

@ -0,0 +1,47 @@
import { Skeleton } from "@/components/ui/skeleton";
export function LeaderboardScoreSkeleton() {
return (
<>
{/* Skeleton for Score Rank */}
<td className="px-4 py-2">
<Skeleton className="w-6 h-4 rounded-md" />
</td>
{/* Skeleton for Player Info */}
<td className="px-4 py-2 flex gap-2">
<Skeleton className="w-24 h-4 rounded-md" />
</td>
{/* Skeleton for Time Set */}
<td className="px-4 py-2 text-center">
<Skeleton className="w-20 h-4 rounded-md mx-auto" />
</td>
{/* Skeleton for Score */}
<td className="px-4 py-2 text-center">
<Skeleton className="w-16 h-4 rounded-md mx-auto" />
</td>
{/* Skeleton for Accuracy */}
<td className="px-4 py-2 text-center">
<Skeleton className="w-16 h-4 rounded-md mx-auto" />
</td>
{/* Skeleton for Misses */}
<td className="px-4 py-2 text-center">
<Skeleton className="w-8 h-4 rounded-md mx-auto" />
</td>
{/* Skeleton for PP */}
<td className="px-4 py-2 text-center">
<Skeleton className="w-12 h-4 rounded-md mx-auto" />
</td>
{/* Skeleton for Modifiers */}
<td className="px-4 py-2 text-center">
<Skeleton className="w-10 h-4 rounded-md mx-auto" />
</td>
</>
);
}

@ -0,0 +1,39 @@
import { Skeleton } from "@/components/ui/skeleton";
import { LeaderboardScoreSkeleton } from "@/components/leaderboard/skeleton/leaderboard-score-skeleton";
export default function LeaderboardScoresSkeleton() {
return (
<>
{/* Loading Skeleton for the LeaderboardScores Table */}
<div className="overflow-x-auto relative">
<table className="table w-full table-auto border-spacing-2 border-none text-left text-sm">
<thead>
<tr>
<th className="px-2 py-1">Rank</th>
<th className="px-2 py-1">Player</th>
<th className="px-2 py-1 text-center">Time Set</th>
<th className="px-2 py-1 text-center">Score</th>
<th className="px-2 py-1 text-center">Accuracy</th>
<th className="px-2 py-1 text-center">Misses</th>
<th className="px-2 py-1 text-center">PP</th>
<th className="px-2 py-1 text-center">Mods</th>
</tr>
</thead>
<tbody>
{/* Loop over to create 10 skeleton rows */}
{[...Array(10)].map((_, index) => (
<tr key={index} className="border-b border-border">
<LeaderboardScoreSkeleton />
</tr>
))}
</tbody>
</table>
</div>
{/* Skeleton for Pagination */}
<div className="flex justify-center mt-4">
<Skeleton className="w-32 h-10 rounded-md" />
</div>
</>
);
}

@ -23,13 +23,39 @@ type TablePlayerProps = {
*/ */
hideCountryFlag?: boolean; hideCountryFlag?: boolean;
/**
* Whether to make the player name a link
*/
useLink?: boolean;
/** /**
* Whether to apply hover brightness * Whether to apply hover brightness
*/ */
hoverBrightness?: boolean; hoverBrightness?: boolean;
}; };
export function PlayerInfo({ player, highlightedPlayer, hideCountryFlag, hoverBrightness = true }: TablePlayerProps) { export function PlayerInfo({
player,
highlightedPlayer,
hideCountryFlag,
useLink,
hoverBrightness = true,
}: TablePlayerProps) {
const name = (
<p
className={clsx(
hoverBrightness ? "transform-gpu transition-all hover:brightness-[66%]" : "",
player.id == highlightedPlayer?.id ? "font-bold" : "",
"text-ellipsis w-[140px] overflow-hidden whitespace-nowrap"
)}
style={{
color: getScoreSaberRole(player)?.color,
}}
>
{player.name}
</p>
);
return ( return (
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<Avatar className="w-[24px] h-[24px] pointer-events-none"> <Avatar className="w-[24px] h-[24px] pointer-events-none">
@ -39,19 +65,7 @@ export function PlayerInfo({ player, highlightedPlayer, hideCountryFlag, hoverBr
/> />
</Avatar> </Avatar>
{!hideCountryFlag && <CountryFlag code={player.country} size={12} />} {!hideCountryFlag && <CountryFlag code={player.country} size={12} />}
<Link {useLink ? <Link href={`/player/${player.id}`}>{name}</Link> : name}
className={clsx(hoverBrightness ? "transform-gpu transition-all hover:brightness-[66%]" : "")}
href={`/player/${player.id}`}
>
<p
className={player.id == highlightedPlayer?.id ? "font-bold" : ""}
style={{
color: getScoreSaberRole(player)?.color,
}}
>
{player.name}
</p>
</Link>
</div> </div>
); );
} }

@ -10,8 +10,6 @@ import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
import { getPlayersAroundPlayer } from "@ssr/common/utils/player-utils"; import { getPlayersAroundPlayer } from "@ssr/common/utils/player-utils";
import { AroundPlayer } from "@ssr/common/types/around-player"; import { AroundPlayer } from "@ssr/common/types/around-player";
import { PlayerInfo } from "@/components/player/player-info"; import { PlayerInfo } from "@/components/player/player-info";
import useDatabase from "@/hooks/use-database";
import { useLiveQuery } from "dexie-react-hooks";
const PLAYER_NAME_MAX_LENGTH = 18; const PLAYER_NAME_MAX_LENGTH = 18;
@ -50,9 +48,6 @@ const miniVariants: Variants = {
}; };
export default function Mini({ type, player, shouldUpdate }: MiniProps) { export default function Mini({ type, player, shouldUpdate }: MiniProps) {
const database = useDatabase();
const claimedPlayer = useLiveQuery(() => database.getClaimedPlayer());
if (shouldUpdate == undefined) { if (shouldUpdate == undefined) {
shouldUpdate = true; shouldUpdate = true;
} }
@ -79,7 +74,7 @@ export default function Mini({ type, player, shouldUpdate }: MiniProps) {
} }
return ( return (
<Card className="w-full flex gap-2 sticky select-none text-sm"> <Card className="flex gap-2 sticky select-none text-sm w-[400px]">
<div className="flex gap-2"> <div className="flex gap-2">
{icon} {icon}
<p>{type} Ranking</p> <p>{type} Ranking</p>
@ -87,10 +82,6 @@ export default function Mini({ type, player, shouldUpdate }: MiniProps) {
<div className="flex flex-col text-xs"> <div className="flex flex-col text-xs">
{response.players.map((playerRanking, index) => { {response.players.map((playerRanking, index) => {
const rank = type == "Global" ? playerRanking.rank : playerRanking.countryRank; const rank = type == "Global" ? playerRanking.rank : playerRanking.countryRank;
const playerName =
playerRanking.name.length > PLAYER_NAME_MAX_LENGTH
? playerRanking.name.substring(0, PLAYER_NAME_MAX_LENGTH) + "..."
: playerRanking.name;
const ppDifference = playerRanking.pp - player.pp; const ppDifference = playerRanking.pp - player.pp;
return ( return (

@ -5,7 +5,7 @@ export function PlayerRankingSkeleton() {
const skeletonArray = new Array(5).fill(0); const skeletonArray = new Array(5).fill(0);
return ( return (
<Card className="w-full flex gap-2 sticky select-none"> <Card className="w-[400px] flex gap-2 sticky select-none">
<div className="flex gap-2"> <div className="flex gap-2">
<Skeleton className="w-6 h-6 rounded-full animate-pulse" /> {/* Icon Skeleton */} <Skeleton className="w-6 h-6 rounded-full animate-pulse" /> {/* Icon Skeleton */}
<Skeleton className="w-32 h-6 animate-pulse" /> {/* Text Skeleton for Ranking */} <Skeleton className="w-32 h-6 animate-pulse" /> {/* Text Skeleton for Ranking */}

@ -21,6 +21,12 @@ export default function ScoreMissesBadge({ score, hideXMark }: ScoreMissesBadgeP
<p className="font-semibold">Misses</p> <p className="font-semibold">Misses</p>
<p>Missed Notes: {formatNumberWithCommas(score.missedNotes)}</p> <p>Missed Notes: {formatNumberWithCommas(score.missedNotes)}</p>
<p>Bad Cuts: {formatNumberWithCommas(score.badCuts)}</p> <p>Bad Cuts: {formatNumberWithCommas(score.badCuts)}</p>
{score.additionalData && (
<>
<p>Bomb Cuts: {formatNumberWithCommas(score.additionalData.bombCuts)}</p>
<p>Wall Hits: {formatNumberWithCommas(score.additionalData.wallsHit)}</p>
</>
)}
</> </>
) : ( ) : (
<p>Full Combo</p> <p>Full Combo</p>

@ -11,9 +11,14 @@ type ScoreModifiersProps = {
* The way to display the modifiers * The way to display the modifiers
*/ */
type: "full" | "simple"; type: "full" | "simple";
/**
* Limit the number of modifiers to display
*/
limit?: number;
}; };
export function ScoreModifiers({ score, type }: ScoreModifiersProps) { export function ScoreModifiers({ score, type, limit }: ScoreModifiersProps) {
const modifiers = score.modifiers; const modifiers = score.modifiers;
if (modifiers.length === 0) { if (modifiers.length === 0) {
return <p>-</p>; return <p>-</p>;
@ -21,13 +26,14 @@ export function ScoreModifiers({ score, type }: ScoreModifiersProps) {
switch (type) { switch (type) {
case "full": case "full":
return <span>{modifiers.join(", ")}</span>; return <span>{modifiers.slice(0, limit).join(", ")}</span>;
case "simple": case "simple":
return ( return (
<span> <span>
{Object.entries(Modifier) {Object.entries(Modifier)
.filter(([_, mod]) => modifiers.includes(mod)) .filter(([_, mod]) => modifiers.includes(mod))
.map(([mod, _]) => mod) .map(([mod, _]) => mod)
.slice(0, limit)
.join(",")} .join(",")}
</span> </span>
); );

@ -49,6 +49,7 @@ const badges: ScoreBadge[] = [
}, },
create: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => { create: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => {
const acc = (score.score / leaderboard.maxScore) * 100; const acc = (score.score / leaderboard.maxScore) * 100;
const fcAccuracy = score.additionalData?.fcAccuracy;
const scoreBadge = getScoreBadgeFromAccuracy(acc); const scoreBadge = getScoreBadgeFromAccuracy(acc);
let accDetails = `${scoreBadge.name != "-" ? scoreBadge.name : ""}`; let accDetails = `${scoreBadge.name != "-" ? scoreBadge.name : ""}`;
if (scoreBadge.max == null) { if (scoreBadge.max == null) {
@ -68,7 +69,8 @@ const badges: ScoreBadge[] = [
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div> <div>
<p className="font-semibold">Accuracy</p> <p className="font-semibold">Accuracy</p>
<p>{accDetails}</p> <p>Score: {accDetails}</p>
{fcAccuracy && <p>Full Combo: {fcAccuracy.toFixed(2)}%</p>}
</div> </div>
{modCount > 0 && ( {modCount > 0 && (
@ -82,7 +84,7 @@ const badges: ScoreBadge[] = [
} }
> >
<p className="cursor-default"> <p className="cursor-default">
{acc.toFixed(2)}% {modCount > 0 && <ScoreModifiers type="simple" score={score} />} {acc.toFixed(2)}% {modCount > 0 && <ScoreModifiers type="simple" limit={1} score={score} />}
</p> </p>
</Tooltip> </Tooltip>
</> </>
@ -96,12 +98,36 @@ const badges: ScoreBadge[] = [
}, },
}, },
{ {
name: "", name: "Left Hand Accuracy",
create: () => undefined, color: () => "bg-hands-left",
create: (score: ScoreSaberScore) => {
if (!score.additionalData) {
return undefined;
}
const { handAccuracy } = score.additionalData;
return (
<Tooltip display={"Left Hand Accuracy"}>
<p>{handAccuracy.left.toFixed(2)}</p>
</Tooltip>
);
},
}, },
{ {
name: "", name: "Right Hand Accuracy",
create: () => undefined, color: () => "bg-hands-right",
create: (score: ScoreSaberScore) => {
if (!score.additionalData) {
return undefined;
}
const { handAccuracy } = score.additionalData;
return (
<Tooltip display={"Right Hand Accuracy"}>
<p>{handAccuracy.right.toFixed(2)}</p>
</Tooltip>
);
},
}, },
{ {
name: "Full Combo", name: "Full Combo",

@ -13,6 +13,8 @@ import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard"; import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map"; import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map";
import { useIsMobile } from "@/hooks/use-is-mobile"; import { useIsMobile } from "@/hooks/use-is-mobile";
import Card from "@/components/card";
import StatValue from "@/components/stat-value";
type Props = { type Props = {
/** /**
@ -103,11 +105,19 @@ export default function Score({ leaderboard, beatSaverMap, score, settings }: Pr
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="w-full mt-2" className="w-full mt-2"
> >
<Card className="flex gap-4 w-full relative border border-input">
{score.additionalData && (
<div className="flex w-full items-center justify-center gap-2">
<StatValue name="Pauses" value={score.additionalData.pauses} />
</div>
)}
<LeaderboardScores <LeaderboardScores
initialPage={getPageFromRank(score.rank, 12)} initialPage={getPageFromRank(score.rank, 12)}
leaderboard={leaderboard} leaderboard={leaderboard}
disableUrlChanging disableUrlChanging
/> />
</Card>
</motion.div> </motion.div>
)} )}
</div> </div>

@ -14,6 +14,10 @@ const config: Config = {
ssr: { ssr: {
DEFAULT: "#6773ff", DEFAULT: "#6773ff",
}, },
hands: {
left: "rgba(168,32,32,1)",
right: "rgba(32,100,168,1)",
},
background: "hsl(var(--background))", background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))", foreground: "hsl(var(--foreground))",
card: { card: {