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",
|
"author": "fascinated7",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"concurrently": "^9.0.1"
|
"concurrently": "^9.0.1",
|
||||||
|
"cross-env": "^7.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Client, MetadataStorage } from "discordx";
|
import { Client, MetadataStorage } from "discordx";
|
||||||
import { Config } from "@ssr/common/config";
|
|
||||||
import { ActivityType, EmbedBuilder } from "discord.js";
|
import { ActivityType, EmbedBuilder } from "discord.js";
|
||||||
|
import { Config } from "@ssr/common/config";
|
||||||
|
|
||||||
export enum DiscordChannels {
|
export enum DiscordChannels {
|
||||||
trackedPlayerLogs = "1295985197262569512",
|
trackedPlayerLogs = "1295985197262569512",
|
||||||
@ -12,6 +12,7 @@ const DiscordBot = new Client({
|
|||||||
intents: [],
|
intents: [],
|
||||||
presence: {
|
presence: {
|
||||||
status: "online",
|
status: "online",
|
||||||
|
|
||||||
activities: [
|
activities: [
|
||||||
{
|
{
|
||||||
name: "scores...",
|
name: "scores...",
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { SSRCache } from "@ssr/common/cache";
|
import { SSRCache } from "@ssr/common/cache";
|
||||||
import { InternalServerError } from "../error/internal-server-error";
|
import { InternalServerError } from "../error/internal-server-error";
|
||||||
|
import { isProduction } from "@ssr/common/utils/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches data with caching.
|
* Fetches data with caching.
|
||||||
@ -13,6 +14,10 @@ export async function fetchWithCache<T>(
|
|||||||
cacheKey: string,
|
cacheKey: string,
|
||||||
fetchFn: () => Promise<T | undefined>
|
fetchFn: () => Promise<T | undefined>
|
||||||
): Promise<T | undefined> {
|
): Promise<T | undefined> {
|
||||||
|
if (!isProduction()) {
|
||||||
|
return await fetchFn();
|
||||||
|
}
|
||||||
|
|
||||||
if (cache == undefined) {
|
if (cache == undefined) {
|
||||||
throw new InternalServerError(`Cache is not defined`);
|
throw new InternalServerError(`Cache is not defined`);
|
||||||
}
|
}
|
||||||
|
@ -14,16 +14,16 @@ import { PlayerService } from "./service/player.service";
|
|||||||
import { cron } from "@elysiajs/cron";
|
import { cron } from "@elysiajs/cron";
|
||||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||||
import { delay, isProduction } from "@ssr/common/utils/utils";
|
import { delay, isProduction } from "@ssr/common/utils/utils";
|
||||||
import { connectScoreSaberWebSocket } from "@ssr/common/websocket/scoresaber-websocket";
|
|
||||||
import ImageController from "./controller/image.controller";
|
import ImageController from "./controller/image.controller";
|
||||||
import { ScoreService } from "./service/score.service";
|
import { ScoreService } from "./service/score.service";
|
||||||
import { Config } from "@ssr/common/config";
|
import { Config } from "@ssr/common/config";
|
||||||
import { PlayerDocument, PlayerModel } from "@ssr/common/model/player";
|
import { PlayerDocument, PlayerModel } from "@ssr/common/model/player";
|
||||||
import ScoresController from "./controller/scores.controller";
|
import ScoresController from "./controller/scores.controller";
|
||||||
import LeaderboardController from "./controller/leaderboard.controller";
|
import LeaderboardController from "./controller/leaderboard.controller";
|
||||||
import { DiscordChannels, initDiscordBot, logToChannel } from "./bot/bot";
|
|
||||||
import { EmbedBuilder } from "discord.js";
|
|
||||||
import { getAppVersion } from "./common/app.util";
|
import { getAppVersion } from "./common/app.util";
|
||||||
|
import { connectScoresaberWebsocket } from "@ssr/common/websocket/scoresaber-websocket";
|
||||||
|
import { connectBeatLeaderWebsocket } from "@ssr/common/websocket/beatleader-websocket";
|
||||||
|
import { initDiscordBot } from "./bot/bot";
|
||||||
|
|
||||||
// Load .env file
|
// Load .env file
|
||||||
dotenv.config({
|
dotenv.config({
|
||||||
@ -35,16 +35,15 @@ dotenv.config({
|
|||||||
await mongoose.connect(Config.mongoUri!); // Connect to MongoDB
|
await mongoose.connect(Config.mongoUri!); // Connect to MongoDB
|
||||||
setLogLevel("DEBUG");
|
setLogLevel("DEBUG");
|
||||||
|
|
||||||
connectScoreSaberWebSocket({
|
connectScoresaberWebsocket({
|
||||||
onScore: async playerScore => {
|
onScore: async score => {
|
||||||
await PlayerService.trackScore(playerScore);
|
await ScoreService.trackScoreSaberScore(score);
|
||||||
await ScoreService.notifyNumberOne(playerScore);
|
await ScoreService.notifyNumberOne(score);
|
||||||
},
|
},
|
||||||
onDisconnect: async error => {
|
});
|
||||||
await logToChannel(
|
connectBeatLeaderWebsocket({
|
||||||
DiscordChannels.backendLogs,
|
onScore: async score => {
|
||||||
new EmbedBuilder().setDescription(`ScoreSaber websocket disconnected: ${error}`)
|
await ScoreService.trackBeatLeaderScore(score);
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -4,7 +4,6 @@ import { getDaysAgoDate, getMidnightAlignedDate } from "@ssr/common/utils/time-u
|
|||||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||||
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
|
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
|
||||||
import { InternalServerError } from "../error/internal-server-error";
|
import { InternalServerError } from "../error/internal-server-error";
|
||||||
import ScoreSaberPlayerScoreToken from "@ssr/common/types/token/scoresaber/score-saber-player-score-token";
|
|
||||||
import { formatPp } from "@ssr/common/utils/number-utils";
|
import { formatPp } from "@ssr/common/utils/number-utils";
|
||||||
import { getPageFromRank, isProduction } from "@ssr/common/utils/utils";
|
import { getPageFromRank, isProduction } from "@ssr/common/utils/utils";
|
||||||
import { DiscordChannels, logToChannel } from "../bot/bot";
|
import { DiscordChannels, logToChannel } from "../bot/bot";
|
||||||
@ -171,46 +170,6 @@ export class PlayerService {
|
|||||||
console.log(`Tracked player "${foundPlayer.id}"!`);
|
console.log(`Tracked player "${foundPlayer.id}"!`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Track player score.
|
|
||||||
*
|
|
||||||
* @param score the score to track
|
|
||||||
* @param leaderboard the leaderboard to track
|
|
||||||
*/
|
|
||||||
public static async trackScore({ score, leaderboard }: ScoreSaberPlayerScoreToken) {
|
|
||||||
const playerId = score.leaderboardPlayerInfo.id;
|
|
||||||
const playerName = score.leaderboardPlayerInfo.name;
|
|
||||||
const player: PlayerDocument | null = await PlayerModel.findById(playerId);
|
|
||||||
// Player is not tracked, so ignore the score.
|
|
||||||
if (player == undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
const history = player.getHistoryByDate(today);
|
|
||||||
const scores = history.scores || {
|
|
||||||
rankedScores: 0,
|
|
||||||
unrankedScores: 0,
|
|
||||||
};
|
|
||||||
if (leaderboard.stars > 0) {
|
|
||||||
scores.rankedScores!++;
|
|
||||||
} else {
|
|
||||||
scores.unrankedScores!++;
|
|
||||||
}
|
|
||||||
|
|
||||||
history.scores = scores;
|
|
||||||
player.setStatisticHistory(today, history);
|
|
||||||
player.sortStatisticHistory();
|
|
||||||
|
|
||||||
// Save the changes
|
|
||||||
player.markModified("statisticHistory");
|
|
||||||
await player.save();
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Updated scores set statistic for "${playerName}"(${playerId}), scores today: ${scores.rankedScores} ranked, ${scores.unrankedScores} unranked`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the players around a player.
|
* Gets the players around a player.
|
||||||
*
|
*
|
||||||
|
@ -23,6 +23,9 @@ import { EmbedBuilder } from "discord.js";
|
|||||||
import { Config } from "@ssr/common/config";
|
import { Config } from "@ssr/common/config";
|
||||||
import { SSRCache } from "@ssr/common/cache";
|
import { SSRCache } from "@ssr/common/cache";
|
||||||
import { fetchWithCache } from "../common/cache.util";
|
import { fetchWithCache } from "../common/cache.util";
|
||||||
|
import { PlayerDocument, PlayerModel } from "@ssr/common/model/player";
|
||||||
|
import { BeatLeaderScoreToken } from "@ssr/common/types/token/beatleader/beatleader-score-token";
|
||||||
|
import { AdditionalScoreData, AdditionalScoreDataModel } from "@ssr/common/model/additional-score-data";
|
||||||
|
|
||||||
const playerScoresCache = new SSRCache({
|
const playerScoresCache = new SSRCache({
|
||||||
ttl: 1000 * 60, // 1 minute
|
ttl: 1000 * 60, // 1 minute
|
||||||
@ -111,6 +114,107 @@ export class ScoreService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks ScoreSaber score.
|
||||||
|
*
|
||||||
|
* @param score the score to track
|
||||||
|
* @param leaderboard the leaderboard to track
|
||||||
|
*/
|
||||||
|
public static async trackScoreSaberScore({ score, leaderboard }: ScoreSaberPlayerScoreToken) {
|
||||||
|
const playerId = score.leaderboardPlayerInfo.id;
|
||||||
|
const playerName = score.leaderboardPlayerInfo.name;
|
||||||
|
const player: PlayerDocument | null = await PlayerModel.findById(playerId);
|
||||||
|
// Player is not tracked, so ignore the score.
|
||||||
|
if (player == undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const history = player.getHistoryByDate(today);
|
||||||
|
const scores = history.scores || {
|
||||||
|
rankedScores: 0,
|
||||||
|
unrankedScores: 0,
|
||||||
|
};
|
||||||
|
if (leaderboard.stars > 0) {
|
||||||
|
scores.rankedScores!++;
|
||||||
|
} else {
|
||||||
|
scores.unrankedScores!++;
|
||||||
|
}
|
||||||
|
|
||||||
|
history.scores = scores;
|
||||||
|
player.setStatisticHistory(today, history);
|
||||||
|
player.sortStatisticHistory();
|
||||||
|
|
||||||
|
// Save the changes
|
||||||
|
player.markModified("statisticHistory");
|
||||||
|
await player.save();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Updated scores set statistic for "${playerName}"(${playerId}), scores today: ${scores.rankedScores} ranked, ${scores.unrankedScores} unranked`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks BeatLeader score.
|
||||||
|
*
|
||||||
|
* @param score the score to track
|
||||||
|
*/
|
||||||
|
public static async trackBeatLeaderScore(score: BeatLeaderScoreToken) {
|
||||||
|
const { playerId, player: scorePlayer, leaderboard } = score;
|
||||||
|
const player: PlayerDocument | null = await PlayerModel.findById(playerId);
|
||||||
|
// Player is not tracked, so ignore the score.
|
||||||
|
if (player == undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const difficulty = leaderboard.difficulty;
|
||||||
|
const difficultyKey = `${difficulty.difficultyName.replace("Plus", "+")}-${difficulty.modeName}`;
|
||||||
|
await AdditionalScoreDataModel.create({
|
||||||
|
playerId: playerId,
|
||||||
|
songHash: leaderboard.song.hash,
|
||||||
|
songDifficulty: difficultyKey,
|
||||||
|
songScore: score.baseScore,
|
||||||
|
bombCuts: score.bombCuts,
|
||||||
|
wallsHit: score.wallsHit,
|
||||||
|
pauses: score.pauses,
|
||||||
|
fcAccuracy: score.fcAccuracy * 100,
|
||||||
|
handAccuracy: {
|
||||||
|
left: score.accLeft,
|
||||||
|
right: score.accRight,
|
||||||
|
},
|
||||||
|
} as AdditionalScoreData);
|
||||||
|
console.log(
|
||||||
|
`Tracked additional score data for "${scorePlayer.name}"(${playerId}), difficulty: ${difficultyKey}, score: ${score.baseScore}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the additional score data for a player's score.
|
||||||
|
*
|
||||||
|
* @param playerId the id of the player
|
||||||
|
* @param songHash the hash of the map
|
||||||
|
* @param songDifficulty the difficulty of the map
|
||||||
|
* @param songScore the score of the play
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private static async getAdditionalScoreData(
|
||||||
|
playerId: string,
|
||||||
|
songHash: string,
|
||||||
|
songDifficulty: string,
|
||||||
|
songScore: number
|
||||||
|
): Promise<AdditionalScoreData | undefined> {
|
||||||
|
const additionalData = await AdditionalScoreDataModel.findOne({
|
||||||
|
playerId: playerId,
|
||||||
|
songHash: songHash,
|
||||||
|
songDifficulty: songDifficulty,
|
||||||
|
songScore: songScore,
|
||||||
|
});
|
||||||
|
if (!additionalData) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return additionalData.toObject();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets scores for a player.
|
* Gets scores for a player.
|
||||||
*
|
*
|
||||||
@ -128,12 +232,12 @@ export class ScoreService {
|
|||||||
sort: string,
|
sort: string,
|
||||||
search?: string
|
search?: string
|
||||||
): Promise<PlayerScoresResponse<unknown, unknown> | undefined> {
|
): Promise<PlayerScoresResponse<unknown, unknown> | undefined> {
|
||||||
|
console.log("hi");
|
||||||
return fetchWithCache(
|
return fetchWithCache(
|
||||||
playerScoresCache,
|
playerScoresCache,
|
||||||
`player-scores-${leaderboardName}-${id}-${page}-${sort}-${search}`,
|
`player-scores-${leaderboardName}-${id}-${page}-${sort}-${search}`,
|
||||||
async () => {
|
async () => {
|
||||||
const scores: PlayerScore<unknown, unknown>[] | undefined = [];
|
const scores: PlayerScore<unknown, unknown>[] | undefined = [];
|
||||||
let beatSaverMap: BeatSaverMap | undefined;
|
|
||||||
let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values
|
let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values
|
||||||
|
|
||||||
switch (leaderboardName) {
|
switch (leaderboardName) {
|
||||||
@ -164,12 +268,22 @@ export class ScoreService {
|
|||||||
if (tokenLeaderboard == undefined) {
|
if (tokenLeaderboard == undefined) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
beatSaverMap = await BeatSaverService.getMap(tokenLeaderboard.songHash);
|
|
||||||
|
const additionalData = await this.getAdditionalScoreData(
|
||||||
|
id,
|
||||||
|
tokenLeaderboard.songHash,
|
||||||
|
`${tokenLeaderboard.difficulty.difficulty}-${tokenLeaderboard.difficulty.gameMode}`,
|
||||||
|
score.score
|
||||||
|
);
|
||||||
|
console.log("additionalData", additionalData);
|
||||||
|
if (additionalData !== undefined) {
|
||||||
|
score.additionalData = additionalData;
|
||||||
|
}
|
||||||
|
|
||||||
scores.push({
|
scores.push({
|
||||||
score: score,
|
score: score,
|
||||||
leaderboard: tokenLeaderboard,
|
leaderboard: tokenLeaderboard,
|
||||||
beatSaver: beatSaverMap,
|
beatSaver: await BeatSaverService.getMap(tokenLeaderboard.songHash),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
@ -41,7 +41,7 @@ export function getScoreSaberLeaderboardFromToken(token: ScoreSaberLeaderboardTo
|
|||||||
const difficulty: LeaderboardDifficulty = {
|
const difficulty: LeaderboardDifficulty = {
|
||||||
leaderboardId: token.difficulty.leaderboardId,
|
leaderboardId: token.difficulty.leaderboardId,
|
||||||
difficulty: getDifficultyFromScoreSaberDifficulty(token.difficulty.difficulty),
|
difficulty: getDifficultyFromScoreSaberDifficulty(token.difficulty.difficulty),
|
||||||
gameMode: token.difficulty.gameMode,
|
gameMode: token.difficulty.gameMode.replace("Solo", ""),
|
||||||
difficultyRaw: token.difficulty.difficultyRaw,
|
difficultyRaw: token.difficulty.difficultyRaw,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -66,7 +66,7 @@ export function getScoreSaberLeaderboardFromToken(token: ScoreSaberLeaderboardTo
|
|||||||
return {
|
return {
|
||||||
leaderboardId: difficulty.leaderboardId,
|
leaderboardId: difficulty.leaderboardId,
|
||||||
difficulty: getDifficultyFromScoreSaberDifficulty(difficulty.difficulty),
|
difficulty: getDifficultyFromScoreSaberDifficulty(difficulty.difficulty),
|
||||||
gameMode: difficulty.gameMode,
|
gameMode: difficulty.gameMode.replace("Solo", ""),
|
||||||
difficultyRaw: difficulty.difficultyRaw,
|
difficultyRaw: difficulty.difficultyRaw,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
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?: {
|
scores?: {
|
||||||
/**
|
/**
|
||||||
@ -60,7 +60,7 @@ export interface PlayerHistory {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The player's accuracy.
|
* The player's accuracy stats.
|
||||||
*/
|
*/
|
||||||
accuracy?: {
|
accuracy?: {
|
||||||
/**
|
/**
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { Modifier } from "./modifier";
|
import { Modifier } from "./modifier";
|
||||||
import { Leaderboards } from "../leaderboard";
|
import { Leaderboards } from "../leaderboard";
|
||||||
|
import { AdditionalScoreData } from "../model/additional-score-data";
|
||||||
|
|
||||||
export default interface Score {
|
export default interface Score {
|
||||||
/**
|
/**
|
||||||
@ -53,6 +54,11 @@ export default interface Score {
|
|||||||
*/
|
*/
|
||||||
readonly fullCombo: boolean;
|
readonly fullCombo: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The additional data for the score.
|
||||||
|
*/
|
||||||
|
additionalData?: AdditionalScoreData;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The time the score was set.
|
* The time the score was set.
|
||||||
* @private
|
* @private
|
||||||
|
@ -0,0 +1,30 @@
|
|||||||
|
import { BeatLeaderModifierToken } from "./beatleader-modifiers-token";
|
||||||
|
import { BeatLeaderModifierRatingToken } from "./beatleader-modifier-rating-token";
|
||||||
|
|
||||||
|
export type BeatLeaderDifficultyToken = {
|
||||||
|
id: number;
|
||||||
|
value: number;
|
||||||
|
mode: number;
|
||||||
|
difficultyName: string;
|
||||||
|
modeName: string;
|
||||||
|
status: number;
|
||||||
|
modifierValues: BeatLeaderModifierToken;
|
||||||
|
modifiersRating: BeatLeaderModifierRatingToken;
|
||||||
|
nominatedTime: number;
|
||||||
|
qualifiedTime: number;
|
||||||
|
rankedTime: number;
|
||||||
|
stars: number;
|
||||||
|
predictedAcc: number;
|
||||||
|
passRating: number;
|
||||||
|
accRating: number;
|
||||||
|
techRating: number;
|
||||||
|
type: number;
|
||||||
|
njs: number;
|
||||||
|
nps: number;
|
||||||
|
notes: number;
|
||||||
|
bombs: number;
|
||||||
|
walls: number;
|
||||||
|
maxScore: number;
|
||||||
|
duration: number;
|
||||||
|
requirements: number;
|
||||||
|
};
|
@ -0,0 +1,16 @@
|
|||||||
|
import { BeatLeaderSongToken } from "./beatleader-song-token";
|
||||||
|
import { BeatLeaderDifficultyToken } from "./beatleader-difficulty-token";
|
||||||
|
|
||||||
|
export type BeatLeaderLeaderboardToken = {
|
||||||
|
id: string;
|
||||||
|
song: BeatLeaderSongToken;
|
||||||
|
difficulty: BeatLeaderDifficultyToken;
|
||||||
|
scores: null; // ??
|
||||||
|
changes: null; // ??
|
||||||
|
qualification: null; // ??
|
||||||
|
reweight: null; // ??
|
||||||
|
leaderboardGroup: null; // ??
|
||||||
|
plays: number;
|
||||||
|
clan: null; // ??
|
||||||
|
clanRankingContested: boolean;
|
||||||
|
};
|
@ -0,0 +1,18 @@
|
|||||||
|
export type BeatLeaderModifierRatingToken = {
|
||||||
|
id: number;
|
||||||
|
fsPredictedAcc: number;
|
||||||
|
fsPassRating: number;
|
||||||
|
fsAccRating: number;
|
||||||
|
fsTechRating: number;
|
||||||
|
fsStars: number;
|
||||||
|
ssPredictedAcc: number;
|
||||||
|
ssPassRating: number;
|
||||||
|
ssAccRating: number;
|
||||||
|
ssTechRating: number;
|
||||||
|
ssStars: number;
|
||||||
|
sfPredictedAcc: number;
|
||||||
|
sfPassRating: number;
|
||||||
|
sfAccRating: number;
|
||||||
|
sfTechRating: number;
|
||||||
|
sfStars: number;
|
||||||
|
};
|
@ -0,0 +1,16 @@
|
|||||||
|
export type BeatLeaderModifierToken = {
|
||||||
|
modifierId: number;
|
||||||
|
da: number;
|
||||||
|
fs: number;
|
||||||
|
sf: number;
|
||||||
|
ss: number;
|
||||||
|
gn: number;
|
||||||
|
na: number;
|
||||||
|
nb: number;
|
||||||
|
nf: number;
|
||||||
|
no: number;
|
||||||
|
pm: number;
|
||||||
|
sc: number;
|
||||||
|
sa: number;
|
||||||
|
op: number;
|
||||||
|
};
|
@ -0,0 +1,10 @@
|
|||||||
|
export type BeatLeaderPlayerToken = {
|
||||||
|
id: string;
|
||||||
|
country: string;
|
||||||
|
avatar: string;
|
||||||
|
pp: number;
|
||||||
|
rank: number;
|
||||||
|
countryRank: number;
|
||||||
|
name: string;
|
||||||
|
// todo: finish this
|
||||||
|
};
|
@ -0,0 +1,19 @@
|
|||||||
|
export type BeatLeaderScoreImprovementToken = {
|
||||||
|
id: number;
|
||||||
|
timeset: number;
|
||||||
|
score: number;
|
||||||
|
accuracy: number;
|
||||||
|
pp: number;
|
||||||
|
bonusPp: number;
|
||||||
|
rank: number;
|
||||||
|
accRight: number;
|
||||||
|
accLeft: number;
|
||||||
|
averageRankedAccuracy: number;
|
||||||
|
totalPp: number;
|
||||||
|
totalRank: number;
|
||||||
|
badCuts: number;
|
||||||
|
missedNotes: number;
|
||||||
|
bombCuts: number;
|
||||||
|
wallsHit: number;
|
||||||
|
pauses: number;
|
||||||
|
};
|
@ -0,0 +1,8 @@
|
|||||||
|
export type BeatLeaderScoreOffsetsToken = {
|
||||||
|
id: number;
|
||||||
|
frames: number;
|
||||||
|
notes: number;
|
||||||
|
walls: number;
|
||||||
|
heights: number;
|
||||||
|
pauses: number;
|
||||||
|
};
|
@ -0,0 +1,52 @@
|
|||||||
|
import { BeatLeaderLeaderboardToken } from "./beatleader-leaderboard-token";
|
||||||
|
import { BeatLeaderScoreImprovementToken } from "./beatleader-score-improvement-token";
|
||||||
|
import { BeatLeaderScoreOffsetsToken } from "./beatleader-score-offsets-token";
|
||||||
|
import { BeatLeaderPlayerToken } from "./beatleader-player-token";
|
||||||
|
|
||||||
|
export type BeatLeaderScoreToken = {
|
||||||
|
myScore: null; // ??
|
||||||
|
validContexts: number;
|
||||||
|
leaderboard: BeatLeaderLeaderboardToken;
|
||||||
|
contextExtensions: null; // ??
|
||||||
|
accLeft: number;
|
||||||
|
accRight: number;
|
||||||
|
id: number;
|
||||||
|
baseScore: number;
|
||||||
|
modifiedScore: number;
|
||||||
|
accuracy: number;
|
||||||
|
playerId: string;
|
||||||
|
pp: number;
|
||||||
|
bonusPp: number;
|
||||||
|
passPP: number;
|
||||||
|
accPP: number;
|
||||||
|
techPP: number;
|
||||||
|
rank: number;
|
||||||
|
country: string;
|
||||||
|
fcAccuracy: number;
|
||||||
|
fcPp: number;
|
||||||
|
weight: number;
|
||||||
|
replay: string;
|
||||||
|
modifiers: string;
|
||||||
|
badCuts: number;
|
||||||
|
missedNotes: number;
|
||||||
|
bombCuts: number;
|
||||||
|
wallsHit: number;
|
||||||
|
pauses: number;
|
||||||
|
fullCombo: boolean;
|
||||||
|
platform: string;
|
||||||
|
maxCombo: number;
|
||||||
|
maxStreak: number;
|
||||||
|
hmd: number;
|
||||||
|
controller: number;
|
||||||
|
leaderboardId: string;
|
||||||
|
timeset: string;
|
||||||
|
timepost: number;
|
||||||
|
replaysWatched: number;
|
||||||
|
playCount: number;
|
||||||
|
priority: number;
|
||||||
|
player: BeatLeaderPlayerToken; // ??
|
||||||
|
scoreImprovement: BeatLeaderScoreImprovementToken;
|
||||||
|
rankVoting: null; // ??
|
||||||
|
metadata: null; // ??
|
||||||
|
offsets: BeatLeaderScoreOffsetsToken;
|
||||||
|
};
|
@ -0,0 +1,16 @@
|
|||||||
|
export type BeatLeaderSongToken = {
|
||||||
|
id: string;
|
||||||
|
hash: string;
|
||||||
|
name: string;
|
||||||
|
subName: string;
|
||||||
|
author: string;
|
||||||
|
mapperId: string;
|
||||||
|
coverImage: string;
|
||||||
|
fullCoverImage: string;
|
||||||
|
downloadUrl: string;
|
||||||
|
bpm: number;
|
||||||
|
duration: number;
|
||||||
|
tags: string;
|
||||||
|
uploadTime: number;
|
||||||
|
difficulties: null; // ??
|
||||||
|
};
|
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 ScoreSaberPlayerScoreToken from "../types/token/scoresaber/score-saber-player-score-token";
|
||||||
|
import { ScoreSaberWebsocketMessageToken } from "../types/token/scoresaber/websocket/scoresaber-websocket-message";
|
||||||
|
|
||||||
type ScoresaberSocket = {
|
type ScoresaberWebsocket = {
|
||||||
/**
|
|
||||||
* Invoked when a general message is received.
|
|
||||||
*
|
|
||||||
* @param message the received message.
|
|
||||||
*/
|
|
||||||
onMessage?: (message: unknown) => void;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invoked when a score message is received.
|
* Invoked when a score message is received.
|
||||||
*
|
*
|
||||||
* @param score the received score data.
|
* @param score the received score data.
|
||||||
*/
|
*/
|
||||||
onScore?: (score: ScoreSaberPlayerScoreToken) => void;
|
onScore?: (score: ScoreSaberPlayerScoreToken) => void;
|
||||||
|
} & WebsocketCallbacks;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invoked when the connection is closed.
|
* Connects to the Scoresaber websocket and handles incoming messages.
|
||||||
*
|
*
|
||||||
* @param error the error that caused the connection to close
|
* @param onMessage the onMessage callback
|
||||||
|
* @param onScore the onScore callback
|
||||||
|
* @param onDisconnect the onDisconnect callback
|
||||||
*/
|
*/
|
||||||
onDisconnect?: (error?: WebSocket.ErrorEvent | WebSocket.CloseEvent) => void;
|
export function connectScoresaberWebsocket({ onMessage, onScore, onDisconnect }: ScoresaberWebsocket) {
|
||||||
};
|
return connectWebSocket({
|
||||||
|
name: "Scoresaber",
|
||||||
/**
|
url: "wss://scoresaber.com/ws",
|
||||||
* Connects to the ScoreSaber websocket and handles incoming messages.
|
onMessage: (message: unknown) => {
|
||||||
*/
|
const command = message as ScoreSaberWebsocketMessageToken;
|
||||||
export function connectScoreSaberWebSocket({ onMessage, onScore, onDisconnect }: ScoresaberSocket) {
|
if (typeof command !== "object" || command === null) {
|
||||||
let websocket: WebSocket | null = null;
|
return;
|
||||||
|
|
||||||
function connectWs() {
|
|
||||||
websocket = new WebSocket("wss://scoresaber.com/ws");
|
|
||||||
|
|
||||||
websocket.onopen = () => {
|
|
||||||
console.log("Connected to the ScoreSaber WebSocket!");
|
|
||||||
};
|
|
||||||
|
|
||||||
websocket.onerror = error => {
|
|
||||||
console.error("WebSocket Error:", error);
|
|
||||||
if (websocket) {
|
|
||||||
websocket.close(); // Close the connection on error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onDisconnect && onDisconnect(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
websocket.onclose = event => {
|
|
||||||
console.log("Lost connection to the ScoreSaber WebSocket. Attempting to reconnect...");
|
|
||||||
|
|
||||||
onDisconnect && onDisconnect(event);
|
|
||||||
setTimeout(connectWs, 5000); // Reconnect after 5 seconds
|
|
||||||
};
|
|
||||||
|
|
||||||
websocket.onmessage = messageEvent => {
|
|
||||||
if (typeof messageEvent.data !== "string") return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const command = JSON.parse(messageEvent.data);
|
|
||||||
|
|
||||||
if (command.commandName === "score") {
|
if (command.commandName === "score") {
|
||||||
onScore && onScore(command.commandData as ScoreSaberPlayerScoreToken);
|
onScore && onScore(command.commandData as ScoreSaberPlayerScoreToken);
|
||||||
} else {
|
} else {
|
||||||
onMessage && onMessage(command);
|
onMessage && onMessage(command);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
},
|
||||||
console.warn("Received invalid message:", messageEvent.data);
|
onDisconnect,
|
||||||
}
|
});
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
connectWs(); // Initiate the first connection
|
|
||||||
}
|
}
|
||||||
|
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,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbo",
|
"dev": "next dev --turbo",
|
||||||
|
"dev-debug": "cross-env NODE_OPTIONS='--inspect' next dev --turbo",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
|
@ -10,6 +10,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { fetchLeaderboard } from "@ssr/common/utils/leaderboard.util";
|
import { fetchLeaderboard } from "@ssr/common/utils/leaderboard.util";
|
||||||
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
|
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
|
||||||
import LeaderboardPpChart from "@/components/leaderboard/leaderboard-pp-chart";
|
import LeaderboardPpChart from "@/components/leaderboard/leaderboard-pp-chart";
|
||||||
|
import Card from "@/components/card";
|
||||||
|
|
||||||
type LeaderboardDataProps = {
|
type LeaderboardDataProps = {
|
||||||
/**
|
/**
|
||||||
@ -48,6 +49,7 @@ export function LeaderboardData({ initialLeaderboard, initialScores, initialPage
|
|||||||
const leaderboard = currentLeaderboard.leaderboard;
|
const leaderboard = currentLeaderboard.leaderboard;
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-col-reverse xl:flex-row w-full gap-2">
|
<main className="flex flex-col-reverse xl:flex-row w-full gap-2">
|
||||||
|
<Card className="flex gap-2 w-full relative">
|
||||||
<LeaderboardScores
|
<LeaderboardScores
|
||||||
leaderboard={leaderboard}
|
leaderboard={leaderboard}
|
||||||
initialScores={initialScores}
|
initialScores={initialScores}
|
||||||
@ -56,6 +58,7 @@ export function LeaderboardData({ initialLeaderboard, initialScores, initialPage
|
|||||||
showDifficulties
|
showDifficulties
|
||||||
isLeaderboardPage
|
isLeaderboardPage
|
||||||
/>
|
/>
|
||||||
|
</Card>
|
||||||
<div className="flex flex-col gap-2 w-full xl:w-[550px]">
|
<div className="flex flex-col gap-2 w-full xl:w-[550px]">
|
||||||
<LeaderboardInfo leaderboard={leaderboard} beatSaverMap={currentLeaderboard.beatsaver} />
|
<LeaderboardInfo leaderboard={leaderboard} beatSaverMap={currentLeaderboard.beatsaver} />
|
||||||
{leaderboard.stars > 0 && <LeaderboardPpChart leaderboard={leaderboard} />}
|
{leaderboard.stars > 0 && <LeaderboardPpChart leaderboard={leaderboard} />}
|
||||||
|
@ -4,12 +4,10 @@ import useWindowDimensions from "@/hooks/use-window-dimensions";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { motion, useAnimation } from "framer-motion";
|
import { motion, useAnimation } from "framer-motion";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import Card from "../card";
|
|
||||||
import Pagination from "../input/pagination";
|
import Pagination from "../input/pagination";
|
||||||
import LeaderboardScore from "./leaderboard-score";
|
import LeaderboardScore from "./leaderboard-score";
|
||||||
import { scoreAnimation } from "@/components/score/score-animation";
|
import { scoreAnimation } from "@/components/score/score-animation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { clsx } from "clsx";
|
|
||||||
import { getDifficultyFromRawDifficulty } from "@/common/song-utils";
|
import { getDifficultyFromRawDifficulty } from "@/common/song-utils";
|
||||||
import { fetchLeaderboardScores } from "@ssr/common/utils/score-utils";
|
import { fetchLeaderboardScores } from "@ssr/common/utils/score-utils";
|
||||||
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
|
import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
|
||||||
@ -17,6 +15,7 @@ import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leade
|
|||||||
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
|
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
|
||||||
import useDatabase from "@/hooks/use-database";
|
import useDatabase from "@/hooks/use-database";
|
||||||
import { useLiveQuery } from "dexie-react-hooks";
|
import { useLiveQuery } from "dexie-react-hooks";
|
||||||
|
import LeaderboardScoresSkeleton from "@/components/leaderboard/skeleton/leaderboard-scores-skeleton";
|
||||||
|
|
||||||
type LeaderboardScoresProps = {
|
type LeaderboardScoresProps = {
|
||||||
initialPage?: number;
|
initialPage?: number;
|
||||||
@ -126,11 +125,11 @@ export default function LeaderboardScores({
|
|||||||
}, [selectedLeaderboardId, currentPage, disableUrlChanging]);
|
}, [selectedLeaderboardId, currentPage, disableUrlChanging]);
|
||||||
|
|
||||||
if (currentScores === undefined) {
|
if (currentScores === undefined) {
|
||||||
return undefined;
|
return <LeaderboardScoresSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={clsx("flex gap-2 w-full relative", !isLeaderboardPage && "border border-input")}>
|
<>
|
||||||
{/* Where to scroll to when new scores are loaded */}
|
{/* Where to scroll to when new scores are loaded */}
|
||||||
<div ref={topOfScoresRef} className="absolute" />
|
<div ref={topOfScoresRef} className="absolute" />
|
||||||
|
|
||||||
@ -207,6 +206,6 @@ export default function LeaderboardScores({
|
|||||||
setShouldFetch(true);
|
setShouldFetch(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,47 @@
|
|||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
export function LeaderboardScoreSkeleton() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Skeleton for Score Rank */}
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<Skeleton className="w-6 h-4 rounded-md" />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Skeleton for Player Info */}
|
||||||
|
<td className="px-4 py-2 flex gap-2">
|
||||||
|
<Skeleton className="w-24 h-4 rounded-md" />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Skeleton for Time Set */}
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
<Skeleton className="w-20 h-4 rounded-md mx-auto" />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Skeleton for Score */}
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
<Skeleton className="w-16 h-4 rounded-md mx-auto" />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Skeleton for Accuracy */}
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
<Skeleton className="w-16 h-4 rounded-md mx-auto" />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Skeleton for Misses */}
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
<Skeleton className="w-8 h-4 rounded-md mx-auto" />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Skeleton for PP */}
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
<Skeleton className="w-12 h-4 rounded-md mx-auto" />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Skeleton for Modifiers */}
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
<Skeleton className="w-10 h-4 rounded-md mx-auto" />
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { LeaderboardScoreSkeleton } from "@/components/leaderboard/skeleton/leaderboard-score-skeleton";
|
||||||
|
|
||||||
|
export default function LeaderboardScoresSkeleton() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Loading Skeleton for the LeaderboardScores Table */}
|
||||||
|
<div className="overflow-x-auto relative">
|
||||||
|
<table className="table w-full table-auto border-spacing-2 border-none text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-1">Rank</th>
|
||||||
|
<th className="px-2 py-1">Player</th>
|
||||||
|
<th className="px-2 py-1 text-center">Time Set</th>
|
||||||
|
<th className="px-2 py-1 text-center">Score</th>
|
||||||
|
<th className="px-2 py-1 text-center">Accuracy</th>
|
||||||
|
<th className="px-2 py-1 text-center">Misses</th>
|
||||||
|
<th className="px-2 py-1 text-center">PP</th>
|
||||||
|
<th className="px-2 py-1 text-center">Mods</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{/* Loop over to create 10 skeleton rows */}
|
||||||
|
{[...Array(10)].map((_, index) => (
|
||||||
|
<tr key={index} className="border-b border-border">
|
||||||
|
<LeaderboardScoreSkeleton />
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skeleton for Pagination */}
|
||||||
|
<div className="flex justify-center mt-4">
|
||||||
|
<Skeleton className="w-32 h-10 rounded-md" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -23,13 +23,39 @@ type TablePlayerProps = {
|
|||||||
*/
|
*/
|
||||||
hideCountryFlag?: boolean;
|
hideCountryFlag?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to make the player name a link
|
||||||
|
*/
|
||||||
|
useLink?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether to apply hover brightness
|
* Whether to apply hover brightness
|
||||||
*/
|
*/
|
||||||
hoverBrightness?: boolean;
|
hoverBrightness?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PlayerInfo({ player, highlightedPlayer, hideCountryFlag, hoverBrightness = true }: TablePlayerProps) {
|
export function PlayerInfo({
|
||||||
|
player,
|
||||||
|
highlightedPlayer,
|
||||||
|
hideCountryFlag,
|
||||||
|
useLink,
|
||||||
|
hoverBrightness = true,
|
||||||
|
}: TablePlayerProps) {
|
||||||
|
const name = (
|
||||||
|
<p
|
||||||
|
className={clsx(
|
||||||
|
hoverBrightness ? "transform-gpu transition-all hover:brightness-[66%]" : "",
|
||||||
|
player.id == highlightedPlayer?.id ? "font-bold" : "",
|
||||||
|
"text-ellipsis w-[140px] overflow-hidden whitespace-nowrap"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
color: getScoreSaberRole(player)?.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{player.name}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<Avatar className="w-[24px] h-[24px] pointer-events-none">
|
<Avatar className="w-[24px] h-[24px] pointer-events-none">
|
||||||
@ -39,19 +65,7 @@ export function PlayerInfo({ player, highlightedPlayer, hideCountryFlag, hoverBr
|
|||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
{!hideCountryFlag && <CountryFlag code={player.country} size={12} />}
|
{!hideCountryFlag && <CountryFlag code={player.country} size={12} />}
|
||||||
<Link
|
{useLink ? <Link href={`/player/${player.id}`}>{name}</Link> : name}
|
||||||
className={clsx(hoverBrightness ? "transform-gpu transition-all hover:brightness-[66%]" : "")}
|
|
||||||
href={`/player/${player.id}`}
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
className={player.id == highlightedPlayer?.id ? "font-bold" : ""}
|
|
||||||
style={{
|
|
||||||
color: getScoreSaberRole(player)?.color,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{player.name}
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -10,8 +10,6 @@ import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
|
|||||||
import { getPlayersAroundPlayer } from "@ssr/common/utils/player-utils";
|
import { getPlayersAroundPlayer } from "@ssr/common/utils/player-utils";
|
||||||
import { AroundPlayer } from "@ssr/common/types/around-player";
|
import { AroundPlayer } from "@ssr/common/types/around-player";
|
||||||
import { PlayerInfo } from "@/components/player/player-info";
|
import { PlayerInfo } from "@/components/player/player-info";
|
||||||
import useDatabase from "@/hooks/use-database";
|
|
||||||
import { useLiveQuery } from "dexie-react-hooks";
|
|
||||||
|
|
||||||
const PLAYER_NAME_MAX_LENGTH = 18;
|
const PLAYER_NAME_MAX_LENGTH = 18;
|
||||||
|
|
||||||
@ -50,9 +48,6 @@ const miniVariants: Variants = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function Mini({ type, player, shouldUpdate }: MiniProps) {
|
export default function Mini({ type, player, shouldUpdate }: MiniProps) {
|
||||||
const database = useDatabase();
|
|
||||||
const claimedPlayer = useLiveQuery(() => database.getClaimedPlayer());
|
|
||||||
|
|
||||||
if (shouldUpdate == undefined) {
|
if (shouldUpdate == undefined) {
|
||||||
shouldUpdate = true;
|
shouldUpdate = true;
|
||||||
}
|
}
|
||||||
@ -79,7 +74,7 @@ export default function Mini({ type, player, shouldUpdate }: MiniProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full flex gap-2 sticky select-none text-sm">
|
<Card className="flex gap-2 sticky select-none text-sm w-[400px]">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{icon}
|
{icon}
|
||||||
<p>{type} Ranking</p>
|
<p>{type} Ranking</p>
|
||||||
@ -87,10 +82,6 @@ export default function Mini({ type, player, shouldUpdate }: MiniProps) {
|
|||||||
<div className="flex flex-col text-xs">
|
<div className="flex flex-col text-xs">
|
||||||
{response.players.map((playerRanking, index) => {
|
{response.players.map((playerRanking, index) => {
|
||||||
const rank = type == "Global" ? playerRanking.rank : playerRanking.countryRank;
|
const rank = type == "Global" ? playerRanking.rank : playerRanking.countryRank;
|
||||||
const playerName =
|
|
||||||
playerRanking.name.length > PLAYER_NAME_MAX_LENGTH
|
|
||||||
? playerRanking.name.substring(0, PLAYER_NAME_MAX_LENGTH) + "..."
|
|
||||||
: playerRanking.name;
|
|
||||||
const ppDifference = playerRanking.pp - player.pp;
|
const ppDifference = playerRanking.pp - player.pp;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -5,7 +5,7 @@ export function PlayerRankingSkeleton() {
|
|||||||
const skeletonArray = new Array(5).fill(0);
|
const skeletonArray = new Array(5).fill(0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full flex gap-2 sticky select-none">
|
<Card className="w-[400px] flex gap-2 sticky select-none">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Skeleton className="w-6 h-6 rounded-full animate-pulse" /> {/* Icon Skeleton */}
|
<Skeleton className="w-6 h-6 rounded-full animate-pulse" /> {/* Icon Skeleton */}
|
||||||
<Skeleton className="w-32 h-6 animate-pulse" /> {/* Text Skeleton for Ranking */}
|
<Skeleton className="w-32 h-6 animate-pulse" /> {/* Text Skeleton for Ranking */}
|
||||||
|
@ -21,6 +21,12 @@ export default function ScoreMissesBadge({ score, hideXMark }: ScoreMissesBadgeP
|
|||||||
<p className="font-semibold">Misses</p>
|
<p className="font-semibold">Misses</p>
|
||||||
<p>Missed Notes: {formatNumberWithCommas(score.missedNotes)}</p>
|
<p>Missed Notes: {formatNumberWithCommas(score.missedNotes)}</p>
|
||||||
<p>Bad Cuts: {formatNumberWithCommas(score.badCuts)}</p>
|
<p>Bad Cuts: {formatNumberWithCommas(score.badCuts)}</p>
|
||||||
|
{score.additionalData && (
|
||||||
|
<>
|
||||||
|
<p>Bomb Cuts: {formatNumberWithCommas(score.additionalData.bombCuts)}</p>
|
||||||
|
<p>Wall Hits: {formatNumberWithCommas(score.additionalData.wallsHit)}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p>Full Combo</p>
|
<p>Full Combo</p>
|
||||||
|
@ -11,9 +11,14 @@ type ScoreModifiersProps = {
|
|||||||
* The way to display the modifiers
|
* The way to display the modifiers
|
||||||
*/
|
*/
|
||||||
type: "full" | "simple";
|
type: "full" | "simple";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limit the number of modifiers to display
|
||||||
|
*/
|
||||||
|
limit?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ScoreModifiers({ score, type }: ScoreModifiersProps) {
|
export function ScoreModifiers({ score, type, limit }: ScoreModifiersProps) {
|
||||||
const modifiers = score.modifiers;
|
const modifiers = score.modifiers;
|
||||||
if (modifiers.length === 0) {
|
if (modifiers.length === 0) {
|
||||||
return <p>-</p>;
|
return <p>-</p>;
|
||||||
@ -21,13 +26,14 @@ export function ScoreModifiers({ score, type }: ScoreModifiersProps) {
|
|||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "full":
|
case "full":
|
||||||
return <span>{modifiers.join(", ")}</span>;
|
return <span>{modifiers.slice(0, limit).join(", ")}</span>;
|
||||||
case "simple":
|
case "simple":
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
{Object.entries(Modifier)
|
{Object.entries(Modifier)
|
||||||
.filter(([_, mod]) => modifiers.includes(mod))
|
.filter(([_, mod]) => modifiers.includes(mod))
|
||||||
.map(([mod, _]) => mod)
|
.map(([mod, _]) => mod)
|
||||||
|
.slice(0, limit)
|
||||||
.join(",")}
|
.join(",")}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
@ -49,6 +49,7 @@ const badges: ScoreBadge[] = [
|
|||||||
},
|
},
|
||||||
create: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => {
|
create: (score: ScoreSaberScore, leaderboard: ScoreSaberLeaderboard) => {
|
||||||
const acc = (score.score / leaderboard.maxScore) * 100;
|
const acc = (score.score / leaderboard.maxScore) * 100;
|
||||||
|
const fcAccuracy = score.additionalData?.fcAccuracy;
|
||||||
const scoreBadge = getScoreBadgeFromAccuracy(acc);
|
const scoreBadge = getScoreBadgeFromAccuracy(acc);
|
||||||
let accDetails = `${scoreBadge.name != "-" ? scoreBadge.name : ""}`;
|
let accDetails = `${scoreBadge.name != "-" ? scoreBadge.name : ""}`;
|
||||||
if (scoreBadge.max == null) {
|
if (scoreBadge.max == null) {
|
||||||
@ -68,7 +69,8 @@ const badges: ScoreBadge[] = [
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Accuracy</p>
|
<p className="font-semibold">Accuracy</p>
|
||||||
<p>{accDetails}</p>
|
<p>Score: {accDetails}</p>
|
||||||
|
{fcAccuracy && <p>Full Combo: {fcAccuracy.toFixed(2)}%</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{modCount > 0 && (
|
{modCount > 0 && (
|
||||||
@ -82,7 +84,7 @@ const badges: ScoreBadge[] = [
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p className="cursor-default">
|
<p className="cursor-default">
|
||||||
{acc.toFixed(2)}% {modCount > 0 && <ScoreModifiers type="simple" score={score} />}
|
{acc.toFixed(2)}% {modCount > 0 && <ScoreModifiers type="simple" limit={1} score={score} />}
|
||||||
</p>
|
</p>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</>
|
</>
|
||||||
@ -96,12 +98,36 @@ const badges: ScoreBadge[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "",
|
name: "Left Hand Accuracy",
|
||||||
create: () => undefined,
|
color: () => "bg-hands-left",
|
||||||
|
create: (score: ScoreSaberScore) => {
|
||||||
|
if (!score.additionalData) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { handAccuracy } = score.additionalData;
|
||||||
|
return (
|
||||||
|
<Tooltip display={"Left Hand Accuracy"}>
|
||||||
|
<p>{handAccuracy.left.toFixed(2)}</p>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "",
|
name: "Right Hand Accuracy",
|
||||||
create: () => undefined,
|
color: () => "bg-hands-right",
|
||||||
|
create: (score: ScoreSaberScore) => {
|
||||||
|
if (!score.additionalData) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { handAccuracy } = score.additionalData;
|
||||||
|
return (
|
||||||
|
<Tooltip display={"Right Hand Accuracy"}>
|
||||||
|
<p>{handAccuracy.right.toFixed(2)}</p>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Full Combo",
|
name: "Full Combo",
|
||||||
|
@ -13,6 +13,8 @@ import ScoreSaberScore from "@ssr/common/score/impl/scoresaber-score";
|
|||||||
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
||||||
import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map";
|
import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map";
|
||||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||||
|
import Card from "@/components/card";
|
||||||
|
import StatValue from "@/components/stat-value";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
/**
|
/**
|
||||||
@ -103,11 +105,19 @@ export default function Score({ leaderboard, beatSaverMap, score, settings }: Pr
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="w-full mt-2"
|
className="w-full mt-2"
|
||||||
>
|
>
|
||||||
|
<Card className="flex gap-4 w-full relative border border-input">
|
||||||
|
{score.additionalData && (
|
||||||
|
<div className="flex w-full items-center justify-center gap-2">
|
||||||
|
<StatValue name="Pauses" value={score.additionalData.pauses} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<LeaderboardScores
|
<LeaderboardScores
|
||||||
initialPage={getPageFromRank(score.rank, 12)}
|
initialPage={getPageFromRank(score.rank, 12)}
|
||||||
leaderboard={leaderboard}
|
leaderboard={leaderboard}
|
||||||
disableUrlChanging
|
disableUrlChanging
|
||||||
/>
|
/>
|
||||||
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -14,6 +14,10 @@ const config: Config = {
|
|||||||
ssr: {
|
ssr: {
|
||||||
DEFAULT: "#6773ff",
|
DEFAULT: "#6773ff",
|
||||||
},
|
},
|
||||||
|
hands: {
|
||||||
|
left: "rgba(168,32,32,1)",
|
||||||
|
right: "rgba(32,100,168,1)",
|
||||||
|
},
|
||||||
background: "hsl(var(--background))",
|
background: "hsl(var(--background))",
|
||||||
foreground: "hsl(var(--foreground))",
|
foreground: "hsl(var(--foreground))",
|
||||||
card: {
|
card: {
|
||||||
|
Reference in New Issue
Block a user