add beatleader data tracking!!!!!!!!!!!!!
This commit is contained in:
parent
074d4de123
commit
fa2ba83c7a
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
@ -13,6 +13,7 @@
|
||||
"author": "fascinated7",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"concurrently": "^9.0.1"
|
||||
"concurrently": "^9.0.1",
|
||||
"cross-env": "^7.0.3"
|
||||
}
|
||||
}
|
||||
|
@ -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...",
|
||||
|
@ -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`);
|
||||
}
|
||||
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -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.
|
||||
*
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
};
|
||||
})
|
||||
|
95
projects/common/src/model/additional-score-data.ts
Normal file
95
projects/common/src/model/additional-score-data.ts
Normal 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);
|
@ -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?: {
|
||||
/**
|
||||
|
@ -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
|
||||
|
@ -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; // ??
|
||||
};
|
30
projects/common/src/websocket/beatleader-websocket.ts
Normal file
30
projects/common/src/websocket/beatleader-websocket.ts
Normal 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,
|
||||
});
|
||||
}
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
89
projects/common/src/websocket/websocket.ts
Normal file
89
projects/common/src/websocket/websocket.ts
Normal 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
|
||||
}
|
@ -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"
|
||||
|
@ -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} />}
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
@ -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 (
|
||||
|
@ -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 */}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
|
@ -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: {
|
||||
|
Reference in New Issue
Block a user