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.

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,6 @@ 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";
import { formatPp } from "@ssr/common/utils/number-utils";
import { getPageFromRank, isProduction } from "@ssr/common/utils/utils";
import { DiscordChannels, logToChannel } from "../bot/bot";
@ -171,46 +170,6 @@ 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 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.
*

View File

@ -23,6 +23,9 @@ import { EmbedBuilder } from "discord.js";
import { Config } from "@ssr/common/config";
import { SSRCache } from "@ssr/common/cache";
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({
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.
*
@ -128,12 +232,12 @@ export class ScoreService {
sort: string,
search?: string
): Promise<PlayerScoresResponse<unknown, unknown> | undefined> {
console.log("hi");
return fetchWithCache(
playerScoresCache,
`player-scores-${leaderboardName}-${id}-${page}-${sort}-${search}`,
async () => {
const scores: PlayerScore<unknown, unknown>[] | undefined = [];
let beatSaverMap: BeatSaverMap | undefined;
let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values
switch (leaderboardName) {
@ -164,12 +268,22 @@ export class ScoreService {
if (tokenLeaderboard == undefined) {
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({
score: score,
leaderboard: tokenLeaderboard,
beatSaver: beatSaverMap,
beatSaver: await BeatSaverService.getMap(tokenLeaderboard.songHash),
});
}
break;

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
};

View File

@ -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;
};

View File

@ -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;
};

View File

@ -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;
};

View File

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

View File

@ -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;
};

View File

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

View File

@ -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;
};

View File

@ -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; // ??
};

View File

@ -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,
});
}

View File

@ -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 { ScoreSaberWebsocketMessageToken } from "../types/token/scoresaber/websocket/scoresaber-websocket-message";
type ScoresaberSocket = {
/**
* Invoked when a general message is received.
*
* @param message the received message.
*/
onMessage?: (message: unknown) => void;
type ScoresaberWebsocket = {
/**
* Invoked when a score message is received.
*
* @param score the received score data.
*/
onScore?: (score: ScoreSaberPlayerScoreToken) => void;
/**
* Invoked when the connection is closed.
*
* @param error the error that caused the connection to close
*/
onDisconnect?: (error?: WebSocket.ErrorEvent | WebSocket.CloseEvent) => void;
};
} & WebsocketCallbacks;
/**
* Connects to the ScoreSaber websocket and handles incoming messages.
* Connects to the Scoresaber websocket and handles incoming messages.
*
* @param onMessage the onMessage callback
* @param onScore the onScore callback
* @param onDisconnect the onDisconnect callback
*/
export function connectScoreSaberWebSocket({ onMessage, onScore, onDisconnect }: ScoresaberSocket) {
let websocket: WebSocket | null = null;
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
export function connectScoresaberWebsocket({ onMessage, onScore, onDisconnect }: ScoresaberWebsocket) {
return connectWebSocket({
name: "Scoresaber",
url: "wss://scoresaber.com/ws",
onMessage: (message: unknown) => {
const command = message as ScoreSaberWebsocketMessageToken;
if (typeof command !== "object" || command === null) {
return;
}
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") {
onScore && onScore(command.commandData as ScoreSaberPlayerScoreToken);
} else {
onMessage && onMessage(command);
}
} catch (err) {
console.warn("Received invalid message:", messageEvent.data);
if (command.commandName === "score") {
onScore && onScore(command.commandData as ScoreSaberPlayerScoreToken);
} else {
onMessage && onMessage(command);
}
};
}
connectWs(); // Initiate the first connection
},
onDisconnect,
});
}

View File

@ -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
}

View File

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

View File

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

View File

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

View File

@ -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>
</>
);
}

View File

@ -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>
</>
);
}

View File

@ -23,13 +23,39 @@ type TablePlayerProps = {
*/
hideCountryFlag?: boolean;
/**
* Whether to make the player name a link
*/
useLink?: boolean;
/**
* Whether to apply hover brightness
*/
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 (
<div className="flex gap-2 items-center">
<Avatar className="w-[24px] h-[24px] pointer-events-none">
@ -39,19 +65,7 @@ export function PlayerInfo({ player, highlightedPlayer, hideCountryFlag, hoverBr
/>
</Avatar>
{!hideCountryFlag && <CountryFlag code={player.country} size={12} />}
<Link
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>
{useLink ? <Link href={`/player/${player.id}`}>{name}</Link> : name}
</div>
);
}

View File

@ -10,8 +10,6 @@ import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
import { getPlayersAroundPlayer } from "@ssr/common/utils/player-utils";
import { AroundPlayer } from "@ssr/common/types/around-player";
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;
@ -50,9 +48,6 @@ const miniVariants: Variants = {
};
export default function Mini({ type, player, shouldUpdate }: MiniProps) {
const database = useDatabase();
const claimedPlayer = useLiveQuery(() => database.getClaimedPlayer());
if (shouldUpdate == undefined) {
shouldUpdate = true;
}
@ -79,7 +74,7 @@ export default function Mini({ type, player, shouldUpdate }: MiniProps) {
}
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">
{icon}
<p>{type} Ranking</p>
@ -87,10 +82,6 @@ export default function Mini({ type, player, shouldUpdate }: MiniProps) {
<div className="flex flex-col text-xs">
{response.players.map((playerRanking, index) => {
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;
return (

View File

@ -5,7 +5,7 @@ export function PlayerRankingSkeleton() {
const skeletonArray = new Array(5).fill(0);
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">
<Skeleton className="w-6 h-6 rounded-full animate-pulse" /> {/* Icon Skeleton */}
<Skeleton className="w-32 h-6 animate-pulse" /> {/* Text Skeleton for Ranking */}

View File

@ -21,6 +21,12 @@ export default function ScoreMissesBadge({ score, hideXMark }: ScoreMissesBadgeP
<p className="font-semibold">Misses</p>
<p>Missed Notes: {formatNumberWithCommas(score.missedNotes)}</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>

View File

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

View File

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

View File

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

View File

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