diff --git a/src/main/java/cc/fascinated/bat/common/MathUtils.java b/src/main/java/cc/fascinated/bat/common/MathUtils.java index e977726..256ee41 100644 --- a/src/main/java/cc/fascinated/bat/common/MathUtils.java +++ b/src/main/java/cc/fascinated/bat/common/MathUtils.java @@ -24,4 +24,28 @@ public final class MathUtils { ).format(number) ); } + + /** + * Clamps a value between a minimum and maximum. + * + * @param value The value to clamp. + * @param min The minimum value. + * @param max The maximum value. + * @return The clamped value. + */ + public static double clamp(double value, double min, double max) { + return Math.max(min, Math.min(max, value)); + } + + /** + * Linearly interpolates between two values. + * + * @param a The first value. + * @param b The second value. + * @param t The interpolation value. + * @return The interpolated value. + */ + public static double lerp(double a, double b, double t) { + return a + t * (b - a); + } } diff --git a/src/main/java/cc/fascinated/bat/common/NumberFormatter.java b/src/main/java/cc/fascinated/bat/common/NumberFormatter.java index ddc1a24..82cad0b 100644 --- a/src/main/java/cc/fascinated/bat/common/NumberFormatter.java +++ b/src/main/java/cc/fascinated/bat/common/NumberFormatter.java @@ -14,7 +14,7 @@ public class NumberFormatter { * The suffixes for the numbers */ private static final String[] SUFFIXES = new String[] { "K", "M", "B", "T", "Q", "QT", "S", "SP", "O", "N", "D", "UD", "DD", "TD" }; - private static final DecimalFormat FORMAT = new DecimalFormat("###.##"); + private static final DecimalFormat FORMAT = new DecimalFormat("#,##0.##"); /** * Format the provided double @@ -47,8 +47,8 @@ public class NumberFormatter { * @param input the value to format * @return the formatted double, in the format of xx,xxx,xxx */ - public static String formatCommas(double input) { - return String.format("%,.0f", input); + public static String simpleFormat(double input) { + return FORMAT.format(input); } /** diff --git a/src/main/java/cc/fascinated/bat/common/beatsaber/leaderboard/Leaderboard.java b/src/main/java/cc/fascinated/bat/common/beatsaber/leaderboard/Leaderboard.java new file mode 100644 index 0000000..f620392 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/common/beatsaber/leaderboard/Leaderboard.java @@ -0,0 +1,22 @@ +package cc.fascinated.bat.common.beatsaber.leaderboard; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor @Getter +public class Leaderboard { + /** + * The name of the leaderboard. + */ + private final String name; + + /** + * The version of the leaderboard. + */ + private int curveVersion; + + /** + * The curve of the leaderboard. + */ + private final LeaderboardCurvePoint[] curve; +} diff --git a/src/main/java/cc/fascinated/bat/common/beatsaber/leaderboard/LeaderboardCurvePoint.java b/src/main/java/cc/fascinated/bat/common/beatsaber/leaderboard/LeaderboardCurvePoint.java new file mode 100644 index 0000000..d1038ad --- /dev/null +++ b/src/main/java/cc/fascinated/bat/common/beatsaber/leaderboard/LeaderboardCurvePoint.java @@ -0,0 +1,10 @@ +package cc.fascinated.bat.common.beatsaber.leaderboard; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter @AllArgsConstructor +public class LeaderboardCurvePoint { + private final double a; + private final double b; +} diff --git a/src/main/java/cc/fascinated/bat/common/beatsaber/leaderboard/impl/ScoreSaberLeaderboard.java b/src/main/java/cc/fascinated/bat/common/beatsaber/leaderboard/impl/ScoreSaberLeaderboard.java new file mode 100644 index 0000000..c2cc977 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/common/beatsaber/leaderboard/impl/ScoreSaberLeaderboard.java @@ -0,0 +1,167 @@ +package cc.fascinated.bat.common.beatsaber.leaderboard.impl; + +import cc.fascinated.bat.common.MathUtils; +import cc.fascinated.bat.common.beatsaber.leaderboard.Leaderboard; +import cc.fascinated.bat.common.beatsaber.leaderboard.LeaderboardCurvePoint; +import cc.fascinated.bat.model.token.beatsaber.scoresaber.ScoreSaberScoreToken; +import lombok.extern.log4j.Log4j2; + +import java.util.ArrayList; +import java.util.List; + +@Log4j2(topic = "ScoreSaber Leaderboard") +public class ScoreSaberLeaderboard extends Leaderboard { + public static final ScoreSaberLeaderboard INSTANCE = new ScoreSaberLeaderboard(); + + /** + * The base multiplier for stars. + */ + private final double starMultiplier = 42.11; + + /** + * no idea, ngl + */ + private final double weightCoefficient = 0.965; + + public ScoreSaberLeaderboard() { + super("ScoreSaber", 1, new LeaderboardCurvePoint[] { + new LeaderboardCurvePoint(1.0, 5.367394282890631), + new LeaderboardCurvePoint(0.9995, 5.019543595874787), + new LeaderboardCurvePoint(0.999, 4.715470646416203), + new LeaderboardCurvePoint(0.99825, 4.325027383589547), + new LeaderboardCurvePoint(0.9975, 3.996793606763322), + new LeaderboardCurvePoint(0.99625, 3.5526145337555373), + new LeaderboardCurvePoint(0.995, 3.2022017597337955), + new LeaderboardCurvePoint(0.99375, 2.9190155639254955), + new LeaderboardCurvePoint(0.9925, 2.685667856592722), + new LeaderboardCurvePoint(0.99125, 2.4902905794106913), + new LeaderboardCurvePoint(0.99, 2.324506282149922), + new LeaderboardCurvePoint(0.9875, 2.058947159052738), + new LeaderboardCurvePoint(0.985, 1.8563887693647105), + new LeaderboardCurvePoint(0.9825, 1.697536248647543), + new LeaderboardCurvePoint(0.98, 1.5702410055532239), + new LeaderboardCurvePoint(0.9775, 1.4664726399289512), + new LeaderboardCurvePoint(0.975, 1.3807102743105126), + new LeaderboardCurvePoint(0.9725, 1.3090333065057616), + new LeaderboardCurvePoint(0.97, 1.2485807759957321), + new LeaderboardCurvePoint(0.965, 1.1552120359501035), + new LeaderboardCurvePoint(0.96, 1.0871883573850478), + new LeaderboardCurvePoint(0.955, 1.0388633331418984), + new LeaderboardCurvePoint(0.95, 1.0), + new LeaderboardCurvePoint(0.94, 0.9417362980580238), + new LeaderboardCurvePoint(0.93, 0.9039994071865736), + new LeaderboardCurvePoint(0.92, 0.8728710341448851), + new LeaderboardCurvePoint(0.91, 0.8488375988124467), + new LeaderboardCurvePoint(0.9, 0.825756123560842), + new LeaderboardCurvePoint(0.875, 0.7816934560296046), + new LeaderboardCurvePoint(0.85, 0.7462290664143185), + new LeaderboardCurvePoint(0.825, 0.7150465663454271), + new LeaderboardCurvePoint(0.8, 0.6872268862950283), + new LeaderboardCurvePoint(0.75, 0.6451808210101443), + new LeaderboardCurvePoint(0.7, 0.6125565959114954), + new LeaderboardCurvePoint(0.65, 0.5866010012767576), + new LeaderboardCurvePoint(0.6, 0.18223233667439062), + new LeaderboardCurvePoint(0.0, 0.0) + }); + } + + /** + * Gets the modifier for the given accuracy. + * + * @param accuracy The accuracy. + * @return The modifier. + */ + public double getModifier(double accuracy) { + accuracy = MathUtils.clamp(accuracy, 0, 100) / 100; + + LeaderboardCurvePoint prev = this.getCurve()[1]; + for (LeaderboardCurvePoint point : this.getCurve()) { + if (point.getA() <= accuracy) { + double distance = (prev.getA() - accuracy) / (prev.getA() - point.getA()); + return MathUtils.lerp(prev.getB(), point.getB(), distance); + } + prev = point; + } + return 0; + } + + /** + * Gets the pp for the given accuracy and stars. + * + * @param accuracy The accuracy. + * @param stars The stars. + * @return The pp. + */ + public double getPP(double accuracy, double stars) { + double pp = stars * this.starMultiplier; + double modifier = this.getModifier(accuracy); + return modifier * pp; + } + + /** + * Gets the total pp for the given scores. + * + * @param scores The scores. + * @return The total pp. + */ + private double getTotalPP(List scores, int startIdx) { + double totalPP = 0; + for (int i = 0; i < scores.size(); i++) { + totalPP += Math.pow(this.weightCoefficient, i + startIdx) * scores.get(i).getPp(); + } + return totalPP; + } + + /** + * Gets the pp at the given index for the given scores. + * + * @param bottomScores The scores. + * @param idx The index. + * @return The pp. + */ + private double getRawPPAtIdx(List bottomScores, int idx, double expected) { + double oldBottomPP = this.getTotalPP(bottomScores, idx); + double newBottomPP = this.getTotalPP(bottomScores, idx + 1); + + return (expected + oldBottomPP - newBottomPP) / Math.pow(this.weightCoefficient, idx); + } + + /** + * Gets the raw pp per global pp for the given scores. + * + * @param scores The scores. + * @param expectedPP The expected pp. + * @return The raw pp per global pp. + */ + public double getRawPerGlobalPP(List scores, double expectedPP) { + int left = 0; + int right = scores.size() - 1; + int boundaryIdx = -1; + + // Sort by PP + scores.sort((a, b) -> Double.compare(b.getPp(), a.getPp())); + + while (left <= right) { + int mid = (left + right) / 2; + double bottomPP = this.getTotalPP(scores.subList(mid, scores.size()), mid); + + List bottomSlice = new ArrayList<>(scores.subList(mid, scores.size())); + bottomSlice.add(0, scores.get(mid)); + double modifiedBottomPP = this.getTotalPP(bottomSlice, mid); + double diff = modifiedBottomPP - bottomPP; + + if (diff > expectedPP) { + boundaryIdx = mid; + left = mid + 1; + } else { + right = mid - 1; + } + } + + if (boundaryIdx == -1) { + return this.getRawPPAtIdx(scores, 0, expectedPP); + } else { + return this.getRawPPAtIdx(scores.subList(boundaryIdx + 1, scores.size()), boundaryIdx + 1, expectedPP); + } + } +} diff --git a/src/main/java/cc/fascinated/bat/features/base/commands/server/MemberCountCommand.java b/src/main/java/cc/fascinated/bat/features/base/commands/server/MemberCountCommand.java index 8aa358c..284e656 100644 --- a/src/main/java/cc/fascinated/bat/features/base/commands/server/MemberCountCommand.java +++ b/src/main/java/cc/fascinated/bat/features/base/commands/server/MemberCountCommand.java @@ -1,6 +1,5 @@ package cc.fascinated.bat.features.base.commands.server; -import cc.fascinated.bat.Emojis; import cc.fascinated.bat.command.BatCommand; import cc.fascinated.bat.command.CommandInfo; import cc.fascinated.bat.common.EmbedUtils; @@ -8,16 +7,12 @@ import cc.fascinated.bat.common.NumberFormatter; import cc.fascinated.bat.model.BatGuild; import cc.fascinated.bat.model.BatUser; import lombok.NonNull; -import net.dv8tion.jda.api.OnlineStatus; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; import net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction; import org.springframework.stereotype.Component; -import java.util.HashMap; -import java.util.Map; - /** * @author Nick (okNick) */ diff --git a/src/main/java/cc/fascinated/bat/features/counter/command/RemoveSubCommand.java b/src/main/java/cc/fascinated/bat/features/counter/command/RemoveSubCommand.java index 37cbdbe..01e400d 100644 --- a/src/main/java/cc/fascinated/bat/features/counter/command/RemoveSubCommand.java +++ b/src/main/java/cc/fascinated/bat/features/counter/command/RemoveSubCommand.java @@ -70,7 +70,7 @@ public class RemoveSubCommand extends BatCommand { """.formatted( Emojis.CHECK_MARK_EMOJI, textChannel.getAsMention(), - NumberFormatter.formatCommas(counterChannel.getCurrentCount()) + NumberFormatter.simpleFormat(counterChannel.getCurrentCount()) )) .build()).queue(); } diff --git a/src/main/java/cc/fascinated/bat/features/logging/listeners/RoleListener.java b/src/main/java/cc/fascinated/bat/features/logging/listeners/RoleListener.java index 30371e7..d2ed614 100644 --- a/src/main/java/cc/fascinated/bat/features/logging/listeners/RoleListener.java +++ b/src/main/java/cc/fascinated/bat/features/logging/listeners/RoleListener.java @@ -2,7 +2,6 @@ package cc.fascinated.bat.features.logging.listeners; import cc.fascinated.bat.common.EmbedDescriptionBuilder; import cc.fascinated.bat.common.EmbedUtils; -import cc.fascinated.bat.common.HexColorUtils; import cc.fascinated.bat.common.RoleUtils; import cc.fascinated.bat.event.EventListener; import cc.fascinated.bat.features.logging.LogFeature; diff --git a/src/main/java/cc/fascinated/bat/features/scoresaber/NumberOneScoreFeedListener.java b/src/main/java/cc/fascinated/bat/features/scoresaber/NumberOneScoreFeedListener.java index 66c3ce9..9d93e1f 100644 --- a/src/main/java/cc/fascinated/bat/features/scoresaber/NumberOneScoreFeedListener.java +++ b/src/main/java/cc/fascinated/bat/features/scoresaber/NumberOneScoreFeedListener.java @@ -45,7 +45,7 @@ public class NumberOneScoreFeedListener implements EventListener { log.info("A new #1 score has been set by {} on {} ({})!", player.getName(), leaderboard.getSongName(), - "%s⭐".formatted(NumberFormatter.formatCommas(leaderboard.getStars())) + "%s⭐".formatted(NumberFormatter.simpleFormat(leaderboard.getStars())) ); for (Guild guild : DiscordService.JDA.getGuilds()) { diff --git a/src/main/java/cc/fascinated/bat/features/scoresaber/ScoreSaberFeature.java b/src/main/java/cc/fascinated/bat/features/scoresaber/ScoreSaberFeature.java index 51609c9..523fdfb 100644 --- a/src/main/java/cc/fascinated/bat/features/scoresaber/ScoreSaberFeature.java +++ b/src/main/java/cc/fascinated/bat/features/scoresaber/ScoreSaberFeature.java @@ -55,10 +55,10 @@ public class ScoreSaberFeature extends Feature { ); String accuracy = leaderboardToken.getMaxScore() == 0 ? "N/A" : - String.format("%s%%", NumberFormatter.formatCommas(((double) scoreToken.getBaseScore() / leaderboardToken.getMaxScore()) * 100)); + String.format("%s%%", NumberFormatter.simpleFormat(((double) scoreToken.getBaseScore() / leaderboardToken.getMaxScore()) * 100)); - String rawPp = scoreToken.getPp() == 0 ? "Unranked" : NumberFormatter.formatCommas(scoreToken.getPp()); - String rank = String.format("#%s", NumberFormatter.formatCommas(scoreToken.getRank())); + String rawPp = scoreToken.getPp() == 0 ? "Unranked" : NumberFormatter.simpleFormat(scoreToken.getPp()); + String rank = String.format("#%s", NumberFormatter.simpleFormat(scoreToken.getRank())); String misses = String.format("%s", scoreToken.getMissedNotes()); String badCuts = String.format("%s", scoreToken.getBadCuts()); String maxCombo = String.format("%s %s", diff --git a/src/main/java/cc/fascinated/bat/features/scoresaber/command/scoresaber/ScoreSaberCommand.java b/src/main/java/cc/fascinated/bat/features/scoresaber/command/scoresaber/ScoreSaberCommand.java index b82d13c..9629f7e 100644 --- a/src/main/java/cc/fascinated/bat/features/scoresaber/command/scoresaber/ScoreSaberCommand.java +++ b/src/main/java/cc/fascinated/bat/features/scoresaber/command/scoresaber/ScoreSaberCommand.java @@ -3,24 +3,17 @@ package cc.fascinated.bat.features.scoresaber.command.scoresaber; import cc.fascinated.bat.command.BatCommand; import cc.fascinated.bat.command.Category; import cc.fascinated.bat.command.CommandInfo; -import cc.fascinated.bat.common.Colors; -import cc.fascinated.bat.common.DateUtils; -import cc.fascinated.bat.common.EmbedUtils; -import cc.fascinated.bat.common.NumberFormatter; -import cc.fascinated.bat.exception.RateLimitException; +import cc.fascinated.bat.common.*; import cc.fascinated.bat.features.scoresaber.profile.user.ScoreSaberProfile; import cc.fascinated.bat.model.BatUser; import cc.fascinated.bat.model.token.beatsaber.scoresaber.ScoreSaberAccountToken; import cc.fascinated.bat.service.ScoreSaberService; import lombok.NonNull; -import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.ApplicationContext; import org.springframework.stereotype.Component; -import java.time.LocalDateTime; - /** * @author Fascinated (fascinated7) */ @@ -42,19 +35,19 @@ public class ScoreSaberCommand extends BatCommand { * * @param user The user to build the profile embed for * @param scoreSaberService The ScoreSaber service - * @param interaction The interaction + * @param event The interaction */ - public static void sendProfileEmbed(boolean isSelf, BatUser user, ScoreSaberService scoreSaberService, SlashCommandInteraction interaction) { + public static void sendProfileEmbed(boolean isSelf, BatUser user, ScoreSaberService scoreSaberService, SlashCommandInteraction event) { ScoreSaberProfile profile = user.getScoreSaberProfile(); if (profile.getAccountId() == null) { if (!isSelf) { - interaction.replyEmbeds(EmbedUtils.errorEmbed() + event.replyEmbeds(EmbedUtils.errorEmbed() .setDescription("%s does not have a linked ScoreSaber account".formatted(user.getDiscordUser().getAsMention())) .build()) .setEphemeral(true) .queue(); } - interaction.replyEmbeds(EmbedUtils.errorEmbed() + event.replyEmbeds(EmbedUtils.errorEmbed() .setDescription("You do not have a linked ScoreSaber account") .build()) .setEphemeral(true) @@ -62,47 +55,89 @@ public class ScoreSaberCommand extends BatCommand { return; } - try { - long before = System.currentTimeMillis(); - boolean cached = scoreSaberService.isCached(profile.getAccountId()); - ScoreSaberAccountToken account = scoreSaberService.getAccount(profile.getAccountId()); - if (account == null) { - if (!isSelf) { - interaction.replyEmbeds(EmbedUtils.errorEmbed() - .setDescription("%s has an invalid ScoreSaber account linked, please ask them to re-link their account" - .formatted(user.getDiscordUser().getAsMention())) - .build()) - .setEphemeral(true) - .queue(); - } - interaction.replyEmbeds(EmbedUtils.errorEmbed() - .setDescription("You have an invalid ScoreSaber account linked, please re-link your account") + long before = System.currentTimeMillis(); + ScoreSaberAccountToken account = scoreSaberService.getAccount(profile.getAccountId()); + if (account == null) { + if (!isSelf) { + event.replyEmbeds(EmbedUtils.errorEmbed() + .setDescription("%s has an invalid ScoreSaber account linked, please ask them to re-link their account" + .formatted(user.getDiscordUser().getAsMention())) .build()) .setEphemeral(true) .queue(); - return; } - - long fetchTime = System.currentTimeMillis() - before; - interaction.replyEmbeds(new EmbedBuilder() - .setAuthor(account.getName() + "'s Profile", "https://scoresaber.com/u/%s".formatted(account.getId()), - "https://cdn.scoresaber.com/avatars/%s.jpg".formatted(account.getId())) - .addField("Name", account.getName(), true) - .addField("Country", account.getCountry(), true) - .addField("Rank", "#" + NumberFormatter.formatCommas(account.getRank()), true) - .addField("Country Rank", "#" + NumberFormatter.formatCommas(account.getCountryRank()), true) - .addField("PP", NumberFormatter.formatCommas(account.getPp()), true) - .addField("Joined", "".formatted(DateUtils.getDateFromString(account.getFirstSeen()).toInstant().getEpochSecond()), true) - .setTimestamp(LocalDateTime.now()) - .setFooter(!cached ? "Fetched in %sms".formatted(fetchTime) : "Cached", "https://flagcdn.com/h120/%s.png".formatted(account.getCountry().toLowerCase())) - .setColor(Colors.DEFAULT) - .build()).queue(); - } catch (RateLimitException ex) { - interaction.replyEmbeds(EmbedUtils.errorEmbed() - .setDescription("The ScoreSaber API is currently rate our limiting requests, please try again later") + event.replyEmbeds(EmbedUtils.errorEmbed() + .setDescription("You have an invalid ScoreSaber account linked, please re-link your account") .build()) .setEphemeral(true) .queue(); + return; } + + if (profile.getAccountId() == null) { + if (!isSelf) { + event.replyEmbeds(EmbedUtils.errorEmbed() + .setDescription("%s does not have a linked ScoreSaber account".formatted(user.getDiscordUser().getAsMention())) + .build()) + .setEphemeral(true) + .queue(); + } + event.replyEmbeds(EmbedUtils.errorEmbed() + .setDescription("You do not have a linked ScoreSaber account") + .build()) + .setEphemeral(true) + .queue(); + return; + } + + event.replyEmbeds(EmbedUtils.genericEmbed() + .setDescription("Loading profile for %s...".formatted(user.getDiscordUser().getAsMention())) + .build()).queue(message -> { + ScoreSaberService.RawPerGlobal rawPerGlobal = scoreSaberService.getRawPerGlobal(profile, (callback) -> { + int currentPage = callback.getCurrentPage(); + int totalPages = callback.getTotalPages(); + // Only update every 5 pages, but show the first page + if (currentPage % 5 != 0 && currentPage != 1) { + return; + } + message.editOriginalEmbeds(EmbedUtils.genericEmbed() + .setDescription(""" + Loading profile for %s... + Page `%s`/`%s` + """.formatted( + user.getDiscordUser().getAsMention(), + currentPage, + totalPages + )) + .build()).queue(); + }); + + String name = account.getName(); + String country = account.getCountry(); + String rank = NumberFormatter.simpleFormat(account.getRank()); + String countryRank = NumberFormatter.simpleFormat(account.getCountryRank()); + String pp = NumberFormatter.simpleFormat(account.getPp()); + String ppPerRawGlobal = NumberFormatter.simpleFormat(rawPerGlobal.getRawPerGlobal()); + long joinedTimeInSeconds = DateUtils.getDateFromString(account.getFirstSeen()).toInstant().getEpochSecond(); + String timeTaken = TimeUtils.format(System.currentTimeMillis() - before, TimeUtils.BatTimeFormat.FIT, true); + + message.editOriginalEmbeds(EmbedUtils.successEmbed() + .setDescription(new EmbedDescriptionBuilder("%s's ScoreSaber Account".formatted(user.getDiscordUser().getAsMention())) + .appendLine("Name: `%s`".formatted(name), true) + .appendLine("Country: `%s`".formatted(country), true) + .appendLine("Rank: `#%s`".formatted(rank), true) + .appendLine("Country Rank: `#%s`".formatted(countryRank), true) + .appendLine("PP: `%spp`".formatted(pp), true) + .appendLine("Raw PP Per Global: `%spp`".formatted(ppPerRawGlobal), true) + .appendLine("Joined: ".formatted(joinedTimeInSeconds), true) + .build()) + .setThumbnail(account.getProfilePicture()) + .setFooter("Took %s (%s/%s cached pages)".formatted( + timeTaken, + rawPerGlobal.getCachedPages(), + rawPerGlobal.getTotalPages() + ), null) + .build()).queue(); + }); } } diff --git a/src/main/java/cc/fascinated/bat/service/ScoreSaberService.java b/src/main/java/cc/fascinated/bat/service/ScoreSaberService.java index d8e2037..847a2c2 100644 --- a/src/main/java/cc/fascinated/bat/service/ScoreSaberService.java +++ b/src/main/java/cc/fascinated/bat/service/ScoreSaberService.java @@ -3,14 +3,14 @@ package cc.fascinated.bat.service; import cc.fascinated.bat.BatApplication; import cc.fascinated.bat.common.DateUtils; import cc.fascinated.bat.common.WebRequest; +import cc.fascinated.bat.common.beatsaber.leaderboard.impl.ScoreSaberLeaderboard; import cc.fascinated.bat.event.EventListener; import cc.fascinated.bat.exception.BadRequestException; import cc.fascinated.bat.exception.ResourceNotFoundException; import cc.fascinated.bat.features.scoresaber.profile.user.ScoreSaberProfile; import cc.fascinated.bat.model.token.beatsaber.scoresaber.*; import com.google.gson.JsonObject; -import lombok.NonNull; -import lombok.SneakyThrows; +import lombok.*; import lombok.extern.log4j.Log4j2; import net.jodah.expiringmap.ExpiringMap; import org.springframework.beans.factory.annotation.Autowired; @@ -27,6 +27,7 @@ import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; @Service @Log4j2(topic = "ScoreSaber Service") @@ -43,6 +44,13 @@ public class ScoreSaberService extends TextWebSocketHandler { .expiration(5, TimeUnit.MINUTES) .build(); + /** + * The cached score pages. + */ + private final Map cachedScorePages = ExpiringMap.builder() + .expiration(30, TimeUnit.MINUTES) + .build(); + @Autowired public ScoreSaberService() { connectWebSocket(); @@ -93,7 +101,15 @@ public class ScoreSaberService extends TextWebSocketHandler { * @param page The page to get the scores from. * @return The scores. */ - public ScoreSaberScoresPageToken getPageScores(ScoreSaberProfile profile, int page) { + public CachedPage getPageScores(ScoreSaberProfile profile, int page) { + String key = profile.getAccountId() + "." + page; + // Check if the page is cached, but don't cache the first page as it's the most likely to change. + if (cachedScorePages.containsKey(key) && page != 1) { + CachedPage cachedPage = cachedScorePages.get(key); + cachedPage.setCached(true); + return cachedPage; + } + log.info("Fetching scores for account '{}' from page {}.", profile.getAccountId(), page); ScoreSaberScoresPageToken pageToken = WebRequest.getAsEntity(String.format(GET_PLAYER_SCORES_ENDPOINT, profile.getAccountId(), "recent", page), ScoreSaberScoresPageToken.class); if (pageToken == null) { // Check if the page doesn't exist. @@ -103,7 +119,9 @@ public class ScoreSaberService extends TextWebSocketHandler { pageToken.setPlayerScores(Arrays.stream(pageToken.getPlayerScores()) .sorted((a, b) -> DateUtils.getDateFromString(b.getScore().getTimeSet()).compareTo(DateUtils.getDateFromString(a.getScore().getTimeSet()))) .toArray(ScoreSaberPlayerScoreToken[]::new)); - return pageToken; + CachedPage cachedPage = new CachedPage(pageToken, false); + cachedScorePages.put(key, cachedPage); + return cachedPage; } /** @@ -112,18 +130,39 @@ public class ScoreSaberService extends TextWebSocketHandler { * @param profile The profile. * @return The scores. */ - public List getScores(ScoreSaberProfile profile) { - List scores = new ArrayList<>(List.of(getPageScores(profile, 1))); - ScoreSaberPageMetadataToken metadata = scores.get(0).getMetadata(); + public List getScores(ScoreSaberProfile profile, Consumer currentPageCallback) { + List scores = new ArrayList<>(List.of(getPageScores(profile, 1))); + ScoreSaberPageMetadataToken metadata = scores.get(0).getPage().getMetadata(); int totalPages = (int) Math.ceil((double) metadata.getTotal() / metadata.getItemsPerPage()); log.info("Fetching {} pages of scores for account '{}'.", totalPages, profile.getAccountId()); for (int i = 2; i <= totalPages; i++) { scores.add(getPageScores(profile, i)); + currentPageCallback.accept(new CurrentPageCallback(i, totalPages)); } - return scores; } + /** + * Gets the raw pp per global pp for the given account. + * + * @param account The account. + * @return The raw pp per global pp. + */ + public RawPerGlobal getRawPerGlobal(ScoreSaberProfile account, Consumer currentPageCallback) { + List scores = getScores(account, currentPageCallback); + List playerScores = new ArrayList<>(); + for (CachedPage score : scores) { + for (ScoreSaberPlayerScoreToken playerScore : score.getPage().getPlayerScores()) { + playerScores.add(playerScore.getScore()); + } + } + return new RawPerGlobal( + ScoreSaberLeaderboard.INSTANCE.getRawPerGlobalPP(playerScores, 1), + (int) scores.stream().filter(CachedPage::isCached).count(), + scores.size() + ); + } + /** * Connects to the ScoreSaber WebSocket. */ @@ -169,4 +208,45 @@ public class ScoreSaberService extends TextWebSocketHandler { log.error("An error occurred while handling the message.", ex); } } + + @AllArgsConstructor + @Getter + public static class CurrentPageCallback { + private int currentPage; + private int totalPages; + } + + @AllArgsConstructor + @Getter + @Setter + public static class CachedPage { + /** + * The page of scores. + */ + private ScoreSaberScoresPageToken page; + + /** + * Whether the page is cached. + */ + private boolean cached; + } + + @AllArgsConstructor + @Getter + public static class RawPerGlobal { + /** + * The raw pp per global pp. + */ + private double rawPerGlobal; + + /** + * The amount of pages that were cached. + */ + private int cachedPages; + + /** + * The total amount of pages. + */ + private int totalPages; + } }