All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 41s
446 lines
15 KiB
TypeScript
446 lines
15 KiB
TypeScript
import ScoreSaberPlayerScoreToken from "@ssr/common/types/token/scoresaber/score-saber-player-score-token";
|
|
import { formatNumberWithCommas, formatPp } from "@ssr/common/utils/number-utils";
|
|
import { isProduction } from "@ssr/common/utils/utils";
|
|
import { Metadata } from "@ssr/common/types/metadata";
|
|
import { NotFoundError } from "elysia";
|
|
import BeatSaverService from "./beatsaver.service";
|
|
import ScoreSaberLeaderboard, {
|
|
getScoreSaberLeaderboardFromToken,
|
|
} from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
|
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
|
import { ScoreSort } from "@ssr/common/score/score-sort";
|
|
import { Leaderboards } from "@ssr/common/leaderboard";
|
|
import Leaderboard from "@ssr/common/leaderboard/leaderboard";
|
|
import LeaderboardService from "./leaderboard.service";
|
|
import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
|
|
import { PlayerScore } from "@ssr/common/score/player-score";
|
|
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
|
|
import PlayerScoresResponse from "@ssr/common/response/player-scores-response";
|
|
import { DiscordChannels, logToChannel } from "../bot/bot";
|
|
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/score/score";
|
|
import {
|
|
AdditionalScoreData,
|
|
AdditionalScoreDataModel,
|
|
} from "@ssr/common/model/additional-score-data/additional-score-data";
|
|
import { BeatLeaderScoreImprovementToken } from "@ssr/common/types/token/beatleader/score/score-improvement";
|
|
import { getScoreSaberScoreFromToken, ScoreSaberScoreModel } from "@ssr/common/model/score/impl/scoresaber-score";
|
|
import { ScoreType } from "@ssr/common/model/score/score";
|
|
|
|
const playerScoresCache = new SSRCache({
|
|
ttl: 1000 * 60, // 1 minute
|
|
});
|
|
|
|
const leaderboardScoresCache = new SSRCache({
|
|
ttl: 1000 * 60, // 1 minute
|
|
});
|
|
|
|
export class ScoreService {
|
|
/**
|
|
* Notifies the number one score in Discord.
|
|
*
|
|
* @param playerScore the score to notify
|
|
*/
|
|
public static async notifyNumberOne(playerScore: ScoreSaberPlayerScoreToken) {
|
|
// Only notify in production
|
|
if (!isProduction()) {
|
|
return;
|
|
}
|
|
|
|
const { score: scoreToken, leaderboard: leaderboardToken } = playerScore;
|
|
const score = getScoreSaberScoreFromToken(scoreToken, leaderboardToken, scoreToken.leaderboardPlayerInfo.id);
|
|
const leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken);
|
|
const playerInfo = score.playerInfo;
|
|
|
|
// Not ranked
|
|
if (leaderboard.stars <= 0) {
|
|
return;
|
|
}
|
|
// Not #1 rank
|
|
if (score.rank !== 1) {
|
|
return;
|
|
}
|
|
|
|
const player = await scoresaberService.lookupPlayer(playerInfo.id);
|
|
if (!player) {
|
|
return;
|
|
}
|
|
|
|
await logToChannel(
|
|
DiscordChannels.numberOneFeed,
|
|
new EmbedBuilder()
|
|
.setTitle(`${player.name} just set a #1!`)
|
|
.setDescription(
|
|
[
|
|
`${leaderboard.songName} ${leaderboard.songSubName} (${leaderboard.difficulty.difficulty} ${leaderboard.stars.toFixed(2)}★)`,
|
|
`[[Player]](${Config.websiteUrl}/player/${player.id}) [[Leaderboard]](${Config.websiteUrl}/leaderboard/${leaderboard.id})`,
|
|
].join("\n")
|
|
)
|
|
.addFields([
|
|
{
|
|
name: "Accuracy",
|
|
value: `${score.accuracy.toFixed(2)}%`,
|
|
inline: true,
|
|
},
|
|
{
|
|
name: "PP",
|
|
value: `${formatPp(score.pp)}pp`,
|
|
inline: true,
|
|
},
|
|
{
|
|
name: "Player Rank",
|
|
value: `#${formatNumberWithCommas(player.rank)}`,
|
|
inline: true,
|
|
},
|
|
{
|
|
name: "Misses",
|
|
value: formatNumberWithCommas(score.missedNotes),
|
|
inline: true,
|
|
},
|
|
{
|
|
name: "Bad Cuts",
|
|
value: formatNumberWithCommas(score.badCuts),
|
|
inline: true,
|
|
},
|
|
{
|
|
name: "Max Combo",
|
|
value: formatNumberWithCommas(score.maxCombo),
|
|
inline: true,
|
|
},
|
|
])
|
|
.setThumbnail(leaderboard.songArt)
|
|
.setTimestamp(score.timestamp)
|
|
.setColor("#00ff00")
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
|
|
const scoreToken = getScoreSaberScoreFromToken(score, leaderboard, playerId);
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-expect-error
|
|
delete scoreToken.playerInfo;
|
|
|
|
// Check if the score already exists
|
|
if (
|
|
await ScoreSaberScoreModel.exists({
|
|
playerId: playerId,
|
|
leaderboardId: leaderboard.id,
|
|
score: scoreToken.score,
|
|
})
|
|
) {
|
|
console.log(
|
|
`Score already exists for "${playerName}"(${playerId}), scoreId=${scoreToken.scoreId}, score=${scoreToken.score}`
|
|
);
|
|
return;
|
|
}
|
|
|
|
await ScoreSaberScoreModel.create(scoreToken);
|
|
console.log(
|
|
`Tracked score and 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;
|
|
}
|
|
|
|
// The score has already been tracked, so ignore it.
|
|
if (
|
|
(await this.getAdditionalScoreData(
|
|
playerId,
|
|
leaderboard.song.hash,
|
|
leaderboard.difficulty.difficultyName,
|
|
score.baseScore
|
|
)) !== undefined
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const getMisses = (score: BeatLeaderScoreToken | BeatLeaderScoreImprovementToken) => {
|
|
return score.missedNotes + score.badCuts + score.bombCuts;
|
|
};
|
|
|
|
const difficulty = leaderboard.difficulty;
|
|
const difficultyKey = `${difficulty.difficultyName}-${difficulty.modeName}`;
|
|
const rawScoreImprovement = score.scoreImprovement;
|
|
const data = {
|
|
playerId: playerId,
|
|
songHash: leaderboard.song.hash.toUpperCase(),
|
|
songDifficulty: difficultyKey,
|
|
songScore: score.baseScore,
|
|
scoreId: score.id,
|
|
leaderboardId: leaderboard.id,
|
|
misses: {
|
|
misses: getMisses(score),
|
|
missedNotes: score.missedNotes,
|
|
bombCuts: score.bombCuts,
|
|
badCuts: score.badCuts,
|
|
wallsHit: score.wallsHit,
|
|
},
|
|
pauses: score.pauses,
|
|
fcAccuracy: score.fcAccuracy * 100,
|
|
fullCombo: score.fullCombo,
|
|
handAccuracy: {
|
|
left: score.accLeft,
|
|
right: score.accRight,
|
|
},
|
|
timestamp: new Date(Number(score.timeset) * 1000),
|
|
} as AdditionalScoreData;
|
|
if (rawScoreImprovement && rawScoreImprovement.score > 0) {
|
|
data.scoreImprovement = {
|
|
score: rawScoreImprovement.score,
|
|
misses: {
|
|
misses: getMisses(rawScoreImprovement),
|
|
missedNotes: rawScoreImprovement.missedNotes,
|
|
bombCuts: rawScoreImprovement.bombCuts,
|
|
badCuts: rawScoreImprovement.badCuts,
|
|
wallsHit: rawScoreImprovement.wallsHit,
|
|
},
|
|
accuracy: rawScoreImprovement.accuracy * 100,
|
|
handAccuracy: {
|
|
left: rawScoreImprovement.accLeft,
|
|
right: rawScoreImprovement.accRight,
|
|
},
|
|
};
|
|
}
|
|
|
|
await AdditionalScoreDataModel.create(data);
|
|
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.toUpperCase(),
|
|
songDifficulty: songDifficulty,
|
|
songScore: songScore,
|
|
});
|
|
if (!additionalData) {
|
|
return undefined;
|
|
}
|
|
return additionalData.toObject();
|
|
}
|
|
|
|
/**
|
|
* Gets scores for a player.
|
|
*
|
|
* @param leaderboardName the leaderboard to get the scores from
|
|
* @param playerId the players id
|
|
* @param page the page to get
|
|
* @param sort the sort to use
|
|
* @param search the search to use
|
|
* @returns the scores
|
|
*/
|
|
public static async getPlayerScores(
|
|
leaderboardName: Leaderboards,
|
|
playerId: string,
|
|
page: number,
|
|
sort: string,
|
|
search?: string
|
|
): Promise<PlayerScoresResponse<unknown, unknown> | undefined> {
|
|
return fetchWithCache(
|
|
playerScoresCache,
|
|
`player-scores-${leaderboardName}-${playerId}-${page}-${sort}-${search}`,
|
|
async () => {
|
|
const scores: PlayerScore<unknown, unknown>[] | undefined = [];
|
|
let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values
|
|
|
|
switch (leaderboardName) {
|
|
case "scoresaber": {
|
|
const leaderboardScores = await scoresaberService.lookupPlayerScores({
|
|
playerId: playerId,
|
|
page: page,
|
|
sort: sort as ScoreSort,
|
|
search: search,
|
|
});
|
|
if (leaderboardScores == undefined) {
|
|
break;
|
|
}
|
|
|
|
metadata = new Metadata(
|
|
Math.ceil(leaderboardScores.metadata.total / leaderboardScores.metadata.itemsPerPage),
|
|
leaderboardScores.metadata.total,
|
|
leaderboardScores.metadata.page,
|
|
leaderboardScores.metadata.itemsPerPage
|
|
);
|
|
|
|
for (const token of leaderboardScores.playerScores) {
|
|
const score = getScoreSaberScoreFromToken(token.score, token.leaderboard, playerId);
|
|
if (score == undefined) {
|
|
continue;
|
|
}
|
|
|
|
const leaderboard = getScoreSaberLeaderboardFromToken(token.leaderboard);
|
|
if (leaderboard == undefined) {
|
|
continue;
|
|
}
|
|
|
|
const additionalData = await this.getAdditionalScoreData(
|
|
playerId,
|
|
leaderboard.songHash,
|
|
`${leaderboard.difficulty.difficulty}-${leaderboard.difficulty.characteristic}`,
|
|
score.score
|
|
);
|
|
if (additionalData !== undefined) {
|
|
score.additionalData = additionalData;
|
|
}
|
|
|
|
scores.push({
|
|
score: score,
|
|
leaderboard: leaderboard,
|
|
beatSaver: await BeatSaverService.getMap(leaderboard.songHash),
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
default: {
|
|
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
scores: scores,
|
|
metadata: metadata,
|
|
};
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Gets scores for a leaderboard.
|
|
*
|
|
* @param leaderboardName the leaderboard to get the scores from
|
|
* @param leaderboardId the leaderboard id
|
|
* @param page the page to get
|
|
* @returns the scores
|
|
*/
|
|
public static async getLeaderboardScores(
|
|
leaderboardName: Leaderboards,
|
|
leaderboardId: string,
|
|
page: number
|
|
): Promise<LeaderboardScoresResponse<unknown, unknown> | undefined> {
|
|
return fetchWithCache(
|
|
leaderboardScoresCache,
|
|
`leaderboard-scores-${leaderboardName}-${leaderboardId}-${page}`,
|
|
async () => {
|
|
const scores: ScoreType[] = [];
|
|
let leaderboard: Leaderboard | undefined;
|
|
let beatSaverMap: BeatSaverMap | undefined;
|
|
let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values
|
|
|
|
switch (leaderboardName) {
|
|
case "scoresaber": {
|
|
const leaderboardResponse = await LeaderboardService.getLeaderboard<ScoreSaberLeaderboard>(
|
|
leaderboardName,
|
|
leaderboardId
|
|
);
|
|
if (leaderboardResponse == undefined) {
|
|
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
|
|
}
|
|
leaderboard = leaderboardResponse.leaderboard;
|
|
beatSaverMap = leaderboardResponse.beatsaver;
|
|
|
|
const leaderboardScores = await scoresaberService.lookupLeaderboardScores(leaderboardId, page);
|
|
if (leaderboardScores == undefined) {
|
|
break;
|
|
}
|
|
|
|
for (const token of leaderboardScores.scores) {
|
|
const score = getScoreSaberScoreFromToken(
|
|
token,
|
|
leaderboardResponse.leaderboard,
|
|
token.leaderboardPlayerInfo.id
|
|
);
|
|
if (score == undefined) {
|
|
continue;
|
|
}
|
|
scores.push(score);
|
|
}
|
|
|
|
metadata = new Metadata(
|
|
Math.ceil(leaderboardScores.metadata.total / leaderboardScores.metadata.itemsPerPage),
|
|
leaderboardScores.metadata.total,
|
|
leaderboardScores.metadata.page,
|
|
leaderboardScores.metadata.itemsPerPage
|
|
);
|
|
break;
|
|
}
|
|
default: {
|
|
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
scores: scores,
|
|
leaderboard: leaderboard,
|
|
beatSaver: beatSaverMap,
|
|
metadata: metadata,
|
|
};
|
|
}
|
|
);
|
|
}
|
|
}
|