diff --git a/bun.lockb b/bun.lockb index 744826b..4b8e430 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 37bd0da..9027e4e 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "author": "fascinated7", "license": "MIT", "dependencies": { - "concurrently": "^9.0.1" + "concurrently": "^9.0.1", + "cross-env": "^7.0.3" } } diff --git a/projects/backend/src/bot/bot.ts b/projects/backend/src/bot/bot.ts index 89f1a86..e97ef15 100644 --- a/projects/backend/src/bot/bot.ts +++ b/projects/backend/src/bot/bot.ts @@ -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...", diff --git a/projects/backend/src/common/cache.util.ts b/projects/backend/src/common/cache.util.ts index a59428a..5e98303 100644 --- a/projects/backend/src/common/cache.util.ts +++ b/projects/backend/src/common/cache.util.ts @@ -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( cacheKey: string, fetchFn: () => Promise ): Promise { + if (!isProduction()) { + return await fetchFn(); + } + if (cache == undefined) { throw new InternalServerError(`Cache is not defined`); } diff --git a/projects/backend/src/index.ts b/projects/backend/src/index.ts index d1c0832..0afb4f1 100644 --- a/projects/backend/src/index.ts +++ b/projects/backend/src/index.ts @@ -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); }, }); diff --git a/projects/backend/src/service/player.service.ts b/projects/backend/src/service/player.service.ts index dd6ed6d..db12917 100644 --- a/projects/backend/src/service/player.service.ts +++ b/projects/backend/src/service/player.service.ts @@ -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. * diff --git a/projects/backend/src/service/score.service.ts b/projects/backend/src/service/score.service.ts index 4c12b97..8717f26 100644 --- a/projects/backend/src/service/score.service.ts +++ b/projects/backend/src/service/score.service.ts @@ -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 { + 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 | undefined> { + console.log("hi"); return fetchWithCache( playerScoresCache, `player-scores-${leaderboardName}-${id}-${page}-${sort}-${search}`, async () => { const scores: PlayerScore[] | 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; diff --git a/projects/common/src/leaderboard/impl/scoresaber-leaderboard.ts b/projects/common/src/leaderboard/impl/scoresaber-leaderboard.ts index 29b7805..df8ef39 100644 --- a/projects/common/src/leaderboard/impl/scoresaber-leaderboard.ts +++ b/projects/common/src/leaderboard/impl/scoresaber-leaderboard.ts @@ -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, }; }) diff --git a/projects/common/src/model/additional-score-data.ts b/projects/common/src/model/additional-score-data.ts new file mode 100644 index 0000000..1db676d --- /dev/null +++ b/projects/common/src/model/additional-score-data.ts @@ -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 = + getModelForClass(AdditionalScoreData); diff --git a/projects/common/src/player/player-history.ts b/projects/common/src/player/player-history.ts index d9f4505..b9fbbc3 100644 --- a/projects/common/src/player/player-history.ts +++ b/projects/common/src/player/player-history.ts @@ -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?: { /** diff --git a/projects/common/src/score/score.ts b/projects/common/src/score/score.ts index f56c09a..2426d8e 100644 --- a/projects/common/src/score/score.ts +++ b/projects/common/src/score/score.ts @@ -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 diff --git a/projects/common/src/types/token/beatleader/beatleader-difficulty-token.ts b/projects/common/src/types/token/beatleader/beatleader-difficulty-token.ts new file mode 100644 index 0000000..ab21362 --- /dev/null +++ b/projects/common/src/types/token/beatleader/beatleader-difficulty-token.ts @@ -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; +}; diff --git a/projects/common/src/types/token/beatleader/beatleader-leaderboard-token.ts b/projects/common/src/types/token/beatleader/beatleader-leaderboard-token.ts new file mode 100644 index 0000000..6cd979a --- /dev/null +++ b/projects/common/src/types/token/beatleader/beatleader-leaderboard-token.ts @@ -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; +}; diff --git a/projects/common/src/types/token/beatleader/beatleader-modifier-rating-token.ts b/projects/common/src/types/token/beatleader/beatleader-modifier-rating-token.ts new file mode 100644 index 0000000..5e0b549 --- /dev/null +++ b/projects/common/src/types/token/beatleader/beatleader-modifier-rating-token.ts @@ -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; +}; diff --git a/projects/common/src/types/token/beatleader/beatleader-modifiers-token.ts b/projects/common/src/types/token/beatleader/beatleader-modifiers-token.ts new file mode 100644 index 0000000..e4b0e34 --- /dev/null +++ b/projects/common/src/types/token/beatleader/beatleader-modifiers-token.ts @@ -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; +}; diff --git a/projects/common/src/types/token/beatleader/beatleader-player-token.ts b/projects/common/src/types/token/beatleader/beatleader-player-token.ts new file mode 100644 index 0000000..e28b811 --- /dev/null +++ b/projects/common/src/types/token/beatleader/beatleader-player-token.ts @@ -0,0 +1,10 @@ +export type BeatLeaderPlayerToken = { + id: string; + country: string; + avatar: string; + pp: number; + rank: number; + countryRank: number; + name: string; + // todo: finish this +}; diff --git a/projects/common/src/types/token/beatleader/beatleader-score-improvement-token.ts b/projects/common/src/types/token/beatleader/beatleader-score-improvement-token.ts new file mode 100644 index 0000000..0740e34 --- /dev/null +++ b/projects/common/src/types/token/beatleader/beatleader-score-improvement-token.ts @@ -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; +}; diff --git a/projects/common/src/types/token/beatleader/beatleader-score-offsets-token.ts b/projects/common/src/types/token/beatleader/beatleader-score-offsets-token.ts new file mode 100644 index 0000000..1d0b1c9 --- /dev/null +++ b/projects/common/src/types/token/beatleader/beatleader-score-offsets-token.ts @@ -0,0 +1,8 @@ +export type BeatLeaderScoreOffsetsToken = { + id: number; + frames: number; + notes: number; + walls: number; + heights: number; + pauses: number; +}; diff --git a/projects/common/src/types/token/beatleader/beatleader-score-token.ts b/projects/common/src/types/token/beatleader/beatleader-score-token.ts new file mode 100644 index 0000000..cf0bf0b --- /dev/null +++ b/projects/common/src/types/token/beatleader/beatleader-score-token.ts @@ -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; +}; diff --git a/projects/common/src/types/token/beatleader/beatleader-song-token.ts b/projects/common/src/types/token/beatleader/beatleader-song-token.ts new file mode 100644 index 0000000..cc92839 --- /dev/null +++ b/projects/common/src/types/token/beatleader/beatleader-song-token.ts @@ -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; // ?? +}; diff --git a/projects/common/src/websocket/beatleader-websocket.ts b/projects/common/src/websocket/beatleader-websocket.ts new file mode 100644 index 0000000..208042e --- /dev/null +++ b/projects/common/src/websocket/beatleader-websocket.ts @@ -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, + }); +} diff --git a/projects/common/src/websocket/scoresaber-websocket.ts b/projects/common/src/websocket/scoresaber-websocket.ts index 636ead4..7b53411 100644 --- a/projects/common/src/websocket/scoresaber-websocket.ts +++ b/projects/common/src/websocket/scoresaber-websocket.ts @@ -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, + }); } diff --git a/projects/common/src/websocket/websocket.ts b/projects/common/src/websocket/websocket.ts new file mode 100644 index 0000000..0354b50 --- /dev/null +++ b/projects/common/src/websocket/websocket.ts @@ -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 +} diff --git a/projects/website/package.json b/projects/website/package.json index 08991db..73d8953 100644 --- a/projects/website/package.json +++ b/projects/website/package.json @@ -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" diff --git a/projects/website/src/components/leaderboard/leaderboard-data.tsx b/projects/website/src/components/leaderboard/leaderboard-data.tsx index 50aea50..1b04552 100644 --- a/projects/website/src/components/leaderboard/leaderboard-data.tsx +++ b/projects/website/src/components/leaderboard/leaderboard-data.tsx @@ -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 (
- setCurrentLeaderboardId(newId)} - showDifficulties - isLeaderboardPage - /> + + setCurrentLeaderboardId(newId)} + showDifficulties + isLeaderboardPage + /> +
{leaderboard.stars > 0 && } diff --git a/projects/website/src/components/leaderboard/leaderboard-scores.tsx b/projects/website/src/components/leaderboard/leaderboard-scores.tsx index 6b3a0c1..ce3da37 100644 --- a/projects/website/src/components/leaderboard/leaderboard-scores.tsx +++ b/projects/website/src/components/leaderboard/leaderboard-scores.tsx @@ -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 ; } return ( - + <> {/* Where to scroll to when new scores are loaded */}
@@ -207,6 +206,6 @@ export default function LeaderboardScores({ setShouldFetch(true); }} /> - + ); } diff --git a/projects/website/src/components/leaderboard/skeleton/leaderboard-score-skeleton.tsx b/projects/website/src/components/leaderboard/skeleton/leaderboard-score-skeleton.tsx new file mode 100644 index 0000000..ca06940 --- /dev/null +++ b/projects/website/src/components/leaderboard/skeleton/leaderboard-score-skeleton.tsx @@ -0,0 +1,47 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export function LeaderboardScoreSkeleton() { + return ( + <> + {/* Skeleton for Score Rank */} + + + + + {/* Skeleton for Player Info */} + + + + + {/* Skeleton for Time Set */} + + + + + {/* Skeleton for Score */} + + + + + {/* Skeleton for Accuracy */} + + + + + {/* Skeleton for Misses */} + + + + + {/* Skeleton for PP */} + + + + + {/* Skeleton for Modifiers */} + + + + + ); +} diff --git a/projects/website/src/components/leaderboard/skeleton/leaderboard-scores-skeleton.tsx b/projects/website/src/components/leaderboard/skeleton/leaderboard-scores-skeleton.tsx new file mode 100644 index 0000000..44faf84 --- /dev/null +++ b/projects/website/src/components/leaderboard/skeleton/leaderboard-scores-skeleton.tsx @@ -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 */} +
+ + + + + + + + + + + + + + + {/* Loop over to create 10 skeleton rows */} + {[...Array(10)].map((_, index) => ( + + + + ))} + +
RankPlayerTime SetScoreAccuracyMissesPPMods
+
+ + {/* Skeleton for Pagination */} +
+ +
+ + ); +} diff --git a/projects/website/src/components/player/player-info.tsx b/projects/website/src/components/player/player-info.tsx index 7c35d98..e910e9e 100644 --- a/projects/website/src/components/player/player-info.tsx +++ b/projects/website/src/components/player/player-info.tsx @@ -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 = ( +

+ {player.name} +

+ ); + return (
@@ -39,19 +65,7 @@ export function PlayerInfo({ player, highlightedPlayer, hideCountryFlag, hoverBr /> {!hideCountryFlag && } - -

- {player.name} -

- + {useLink ? {name} : name}
); } diff --git a/projects/website/src/components/ranking/mini.tsx b/projects/website/src/components/ranking/mini.tsx index f392650..1199092 100644 --- a/projects/website/src/components/ranking/mini.tsx +++ b/projects/website/src/components/ranking/mini.tsx @@ -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 ( - +
{icon}

{type} Ranking

@@ -87,10 +82,6 @@ export default function Mini({ type, player, shouldUpdate }: MiniProps) {
{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 ( diff --git a/projects/website/src/components/ranking/player-ranking-skeleton.tsx b/projects/website/src/components/ranking/player-ranking-skeleton.tsx index be962dc..f4e8c6d 100644 --- a/projects/website/src/components/ranking/player-ranking-skeleton.tsx +++ b/projects/website/src/components/ranking/player-ranking-skeleton.tsx @@ -5,7 +5,7 @@ export function PlayerRankingSkeleton() { const skeletonArray = new Array(5).fill(0); return ( - +
{/* Icon Skeleton */} {/* Text Skeleton for Ranking */} diff --git a/projects/website/src/components/score/badges/score-misses.tsx b/projects/website/src/components/score/badges/score-misses.tsx index 0c0e67a..380d953 100644 --- a/projects/website/src/components/score/badges/score-misses.tsx +++ b/projects/website/src/components/score/badges/score-misses.tsx @@ -21,6 +21,12 @@ export default function ScoreMissesBadge({ score, hideXMark }: ScoreMissesBadgeP

Misses

Missed Notes: {formatNumberWithCommas(score.missedNotes)}

Bad Cuts: {formatNumberWithCommas(score.badCuts)}

+ {score.additionalData && ( + <> +

Bomb Cuts: {formatNumberWithCommas(score.additionalData.bombCuts)}

+

Wall Hits: {formatNumberWithCommas(score.additionalData.wallsHit)}

+ + )} ) : (

Full Combo

diff --git a/projects/website/src/components/score/score-modifiers.tsx b/projects/website/src/components/score/score-modifiers.tsx index 9b87d22..a22832f 100644 --- a/projects/website/src/components/score/score-modifiers.tsx +++ b/projects/website/src/components/score/score-modifiers.tsx @@ -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

-

; @@ -21,13 +26,14 @@ export function ScoreModifiers({ score, type }: ScoreModifiersProps) { switch (type) { case "full": - return {modifiers.join(", ")}; + return {modifiers.slice(0, limit).join(", ")}; case "simple": return ( {Object.entries(Modifier) .filter(([_, mod]) => modifiers.includes(mod)) .map(([mod, _]) => mod) + .slice(0, limit) .join(",")} ); diff --git a/projects/website/src/components/score/score-stats.tsx b/projects/website/src/components/score/score-stats.tsx index 2d343ac..ddc3a8c 100644 --- a/projects/website/src/components/score/score-stats.tsx +++ b/projects/website/src/components/score/score-stats.tsx @@ -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[] = [

Accuracy

-

{accDetails}

+

Score: {accDetails}

+ {fcAccuracy &&

Full Combo: {fcAccuracy.toFixed(2)}%

}
{modCount > 0 && ( @@ -82,7 +84,7 @@ const badges: ScoreBadge[] = [ } >

- {acc.toFixed(2)}% {modCount > 0 && } + {acc.toFixed(2)}% {modCount > 0 && }

@@ -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 ( + +

{handAccuracy.left.toFixed(2)}

+
+ ); + }, }, { - name: "", - create: () => undefined, + name: "Right Hand Accuracy", + color: () => "bg-hands-right", + create: (score: ScoreSaberScore) => { + if (!score.additionalData) { + return undefined; + } + + const { handAccuracy } = score.additionalData; + return ( + +

{handAccuracy.right.toFixed(2)}

+
+ ); + }, }, { name: "Full Combo", diff --git a/projects/website/src/components/score/score.tsx b/projects/website/src/components/score/score.tsx index ce2e0fc..e906e53 100644 --- a/projects/website/src/components/score/score.tsx +++ b/projects/website/src/components/score/score.tsx @@ -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" > - + + {score.additionalData && ( +
+ +
+ )} + + +
)}
diff --git a/projects/website/tailwind.config.ts b/projects/website/tailwind.config.ts index d905875..f703e27 100644 --- a/projects/website/tailwind.config.ts +++ b/projects/website/tailwind.config.ts @@ -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: {