update scoresaber me/user command to show raw per global

This commit is contained in:
Lee 2024-07-05 20:44:43 +01:00
parent bef2b695f5
commit bb81c098b2
12 changed files with 400 additions and 68 deletions

@ -24,4 +24,28 @@ public final class MathUtils {
).format(number) ).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);
}
} }

@ -14,7 +14,7 @@ public class NumberFormatter {
* The suffixes for the numbers * 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 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 * Format the provided double
@ -47,8 +47,8 @@ public class NumberFormatter {
* @param input the value to format * @param input the value to format
* @return the formatted double, in the format of xx,xxx,xxx * @return the formatted double, in the format of xx,xxx,xxx
*/ */
public static String formatCommas(double input) { public static String simpleFormat(double input) {
return String.format("%,.0f", input); return FORMAT.format(input);
} }
/** /**

@ -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;
}

@ -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;
}

@ -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<ScoreSaberScoreToken> 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<ScoreSaberScoreToken> 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<ScoreSaberScoreToken> 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<ScoreSaberScoreToken> 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);
}
}
}

@ -1,6 +1,5 @@
package cc.fascinated.bat.features.base.commands.server; package cc.fascinated.bat.features.base.commands.server;
import cc.fascinated.bat.Emojis;
import cc.fascinated.bat.command.BatCommand; import cc.fascinated.bat.command.BatCommand;
import cc.fascinated.bat.command.CommandInfo; import cc.fascinated.bat.command.CommandInfo;
import cc.fascinated.bat.common.EmbedUtils; 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.BatGuild;
import cc.fascinated.bat.model.BatUser; import cc.fascinated.bat.model.BatUser;
import lombok.NonNull; import lombok.NonNull;
import net.dv8tion.jda.api.OnlineStatus;
import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
import net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction; import net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
/** /**
* @author Nick (okNick) * @author Nick (okNick)
*/ */

@ -70,7 +70,7 @@ public class RemoveSubCommand extends BatCommand {
""".formatted( """.formatted(
Emojis.CHECK_MARK_EMOJI, Emojis.CHECK_MARK_EMOJI,
textChannel.getAsMention(), textChannel.getAsMention(),
NumberFormatter.formatCommas(counterChannel.getCurrentCount()) NumberFormatter.simpleFormat(counterChannel.getCurrentCount())
)) ))
.build()).queue(); .build()).queue();
} }

@ -2,7 +2,6 @@ package cc.fascinated.bat.features.logging.listeners;
import cc.fascinated.bat.common.EmbedDescriptionBuilder; import cc.fascinated.bat.common.EmbedDescriptionBuilder;
import cc.fascinated.bat.common.EmbedUtils; import cc.fascinated.bat.common.EmbedUtils;
import cc.fascinated.bat.common.HexColorUtils;
import cc.fascinated.bat.common.RoleUtils; import cc.fascinated.bat.common.RoleUtils;
import cc.fascinated.bat.event.EventListener; import cc.fascinated.bat.event.EventListener;
import cc.fascinated.bat.features.logging.LogFeature; import cc.fascinated.bat.features.logging.LogFeature;

@ -45,7 +45,7 @@ public class NumberOneScoreFeedListener implements EventListener {
log.info("A new #1 score has been set by {} on {} ({})!", log.info("A new #1 score has been set by {} on {} ({})!",
player.getName(), player.getName(),
leaderboard.getSongName(), leaderboard.getSongName(),
"%s⭐".formatted(NumberFormatter.formatCommas(leaderboard.getStars())) "%s⭐".formatted(NumberFormatter.simpleFormat(leaderboard.getStars()))
); );
for (Guild guild : DiscordService.JDA.getGuilds()) { for (Guild guild : DiscordService.JDA.getGuilds()) {

@ -55,10 +55,10 @@ public class ScoreSaberFeature extends Feature {
); );
String accuracy = leaderboardToken.getMaxScore() == 0 ? "N/A" : 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 rawPp = scoreToken.getPp() == 0 ? "Unranked" : NumberFormatter.simpleFormat(scoreToken.getPp());
String rank = String.format("#%s", NumberFormatter.formatCommas(scoreToken.getRank())); String rank = String.format("#%s", NumberFormatter.simpleFormat(scoreToken.getRank()));
String misses = String.format("%s", scoreToken.getMissedNotes()); String misses = String.format("%s", scoreToken.getMissedNotes());
String badCuts = String.format("%s", scoreToken.getBadCuts()); String badCuts = String.format("%s", scoreToken.getBadCuts());
String maxCombo = String.format("%s %s", String maxCombo = String.format("%s %s",

@ -3,24 +3,17 @@ package cc.fascinated.bat.features.scoresaber.command.scoresaber;
import cc.fascinated.bat.command.BatCommand; import cc.fascinated.bat.command.BatCommand;
import cc.fascinated.bat.command.Category; import cc.fascinated.bat.command.Category;
import cc.fascinated.bat.command.CommandInfo; import cc.fascinated.bat.command.CommandInfo;
import cc.fascinated.bat.common.Colors; import cc.fascinated.bat.common.*;
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.features.scoresaber.profile.user.ScoreSaberProfile; import cc.fascinated.bat.features.scoresaber.profile.user.ScoreSaberProfile;
import cc.fascinated.bat.model.BatUser; import cc.fascinated.bat.model.BatUser;
import cc.fascinated.bat.model.token.beatsaber.scoresaber.ScoreSaberAccountToken; import cc.fascinated.bat.model.token.beatsaber.scoresaber.ScoreSaberAccountToken;
import cc.fascinated.bat.service.ScoreSaberService; import cc.fascinated.bat.service.ScoreSaberService;
import lombok.NonNull; import lombok.NonNull;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction; import net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
/** /**
* @author Fascinated (fascinated7) * @author Fascinated (fascinated7)
*/ */
@ -42,19 +35,19 @@ public class ScoreSaberCommand extends BatCommand {
* *
* @param user The user to build the profile embed for * @param user The user to build the profile embed for
* @param scoreSaberService The ScoreSaber service * @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(); ScoreSaberProfile profile = user.getScoreSaberProfile();
if (profile.getAccountId() == null) { if (profile.getAccountId() == null) {
if (!isSelf) { if (!isSelf) {
interaction.replyEmbeds(EmbedUtils.errorEmbed() event.replyEmbeds(EmbedUtils.errorEmbed()
.setDescription("%s does not have a linked ScoreSaber account".formatted(user.getDiscordUser().getAsMention())) .setDescription("%s does not have a linked ScoreSaber account".formatted(user.getDiscordUser().getAsMention()))
.build()) .build())
.setEphemeral(true) .setEphemeral(true)
.queue(); .queue();
} }
interaction.replyEmbeds(EmbedUtils.errorEmbed() event.replyEmbeds(EmbedUtils.errorEmbed()
.setDescription("You do not have a linked ScoreSaber account") .setDescription("You do not have a linked ScoreSaber account")
.build()) .build())
.setEphemeral(true) .setEphemeral(true)
@ -62,20 +55,18 @@ public class ScoreSaberCommand extends BatCommand {
return; return;
} }
try {
long before = System.currentTimeMillis(); long before = System.currentTimeMillis();
boolean cached = scoreSaberService.isCached(profile.getAccountId());
ScoreSaberAccountToken account = scoreSaberService.getAccount(profile.getAccountId()); ScoreSaberAccountToken account = scoreSaberService.getAccount(profile.getAccountId());
if (account == null) { if (account == null) {
if (!isSelf) { if (!isSelf) {
interaction.replyEmbeds(EmbedUtils.errorEmbed() event.replyEmbeds(EmbedUtils.errorEmbed()
.setDescription("%s has an invalid ScoreSaber account linked, please ask them to re-link their account" .setDescription("%s has an invalid ScoreSaber account linked, please ask them to re-link their account"
.formatted(user.getDiscordUser().getAsMention())) .formatted(user.getDiscordUser().getAsMention()))
.build()) .build())
.setEphemeral(true) .setEphemeral(true)
.queue(); .queue();
} }
interaction.replyEmbeds(EmbedUtils.errorEmbed() event.replyEmbeds(EmbedUtils.errorEmbed()
.setDescription("You have an invalid ScoreSaber account linked, please re-link your account") .setDescription("You have an invalid ScoreSaber account linked, please re-link your account")
.build()) .build())
.setEphemeral(true) .setEphemeral(true)
@ -83,26 +74,70 @@ public class ScoreSaberCommand extends BatCommand {
return; return;
} }
long fetchTime = System.currentTimeMillis() - before; if (profile.getAccountId() == null) {
interaction.replyEmbeds(new EmbedBuilder() if (!isSelf) {
.setAuthor(account.getName() + "'s Profile", "https://scoresaber.com/u/%s".formatted(account.getId()), event.replyEmbeds(EmbedUtils.errorEmbed()
"https://cdn.scoresaber.com/avatars/%s.jpg".formatted(account.getId())) .setDescription("%s does not have a linked ScoreSaber account".formatted(user.getDiscordUser().getAsMention()))
.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", "<t:%s>".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")
.build()) .build())
.setEphemeral(true) .setEphemeral(true)
.queue(); .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: <t:%s>".formatted(joinedTimeInSeconds), true)
.build())
.setThumbnail(account.getProfilePicture())
.setFooter("Took %s (%s/%s cached pages)".formatted(
timeTaken,
rawPerGlobal.getCachedPages(),
rawPerGlobal.getTotalPages()
), null)
.build()).queue();
});
} }
} }

@ -3,14 +3,14 @@ package cc.fascinated.bat.service;
import cc.fascinated.bat.BatApplication; import cc.fascinated.bat.BatApplication;
import cc.fascinated.bat.common.DateUtils; import cc.fascinated.bat.common.DateUtils;
import cc.fascinated.bat.common.WebRequest; 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.event.EventListener;
import cc.fascinated.bat.exception.BadRequestException; import cc.fascinated.bat.exception.BadRequestException;
import cc.fascinated.bat.exception.ResourceNotFoundException; import cc.fascinated.bat.exception.ResourceNotFoundException;
import cc.fascinated.bat.features.scoresaber.profile.user.ScoreSaberProfile; import cc.fascinated.bat.features.scoresaber.profile.user.ScoreSaberProfile;
import cc.fascinated.bat.model.token.beatsaber.scoresaber.*; import cc.fascinated.bat.model.token.beatsaber.scoresaber.*;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
import lombok.NonNull; import lombok.*;
import lombok.SneakyThrows;
import lombok.extern.log4j.Log4j2; import lombok.extern.log4j.Log4j2;
import net.jodah.expiringmap.ExpiringMap; import net.jodah.expiringmap.ExpiringMap;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -27,6 +27,7 @@ import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
@Service @Service
@Log4j2(topic = "ScoreSaber Service") @Log4j2(topic = "ScoreSaber Service")
@ -43,6 +44,13 @@ public class ScoreSaberService extends TextWebSocketHandler {
.expiration(5, TimeUnit.MINUTES) .expiration(5, TimeUnit.MINUTES)
.build(); .build();
/**
* The cached score pages.
*/
private final Map<String, CachedPage> cachedScorePages = ExpiringMap.builder()
.expiration(30, TimeUnit.MINUTES)
.build();
@Autowired @Autowired
public ScoreSaberService() { public ScoreSaberService() {
connectWebSocket(); connectWebSocket();
@ -93,7 +101,15 @@ public class ScoreSaberService extends TextWebSocketHandler {
* @param page The page to get the scores from. * @param page The page to get the scores from.
* @return The scores. * @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); 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); 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. 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()) pageToken.setPlayerScores(Arrays.stream(pageToken.getPlayerScores())
.sorted((a, b) -> DateUtils.getDateFromString(b.getScore().getTimeSet()).compareTo(DateUtils.getDateFromString(a.getScore().getTimeSet()))) .sorted((a, b) -> DateUtils.getDateFromString(b.getScore().getTimeSet()).compareTo(DateUtils.getDateFromString(a.getScore().getTimeSet())))
.toArray(ScoreSaberPlayerScoreToken[]::new)); .toArray(ScoreSaberPlayerScoreToken[]::new));
return pageToken; CachedPage cachedPage = new CachedPage(pageToken, false);
cachedScorePages.put(key, cachedPage);
return cachedPage;
} }
/** /**
@ -112,16 +130,37 @@ public class ScoreSaberService extends TextWebSocketHandler {
* @param profile The profile. * @param profile The profile.
* @return The scores. * @return The scores.
*/ */
public List<ScoreSaberScoresPageToken> getScores(ScoreSaberProfile profile) { public List<CachedPage> getScores(ScoreSaberProfile profile, Consumer<CurrentPageCallback> currentPageCallback) {
List<ScoreSaberScoresPageToken> scores = new ArrayList<>(List.of(getPageScores(profile, 1))); List<CachedPage> scores = new ArrayList<>(List.of(getPageScores(profile, 1)));
ScoreSaberPageMetadataToken metadata = scores.get(0).getMetadata(); ScoreSaberPageMetadataToken metadata = scores.get(0).getPage().getMetadata();
int totalPages = (int) Math.ceil((double) metadata.getTotal() / metadata.getItemsPerPage()); int totalPages = (int) Math.ceil((double) metadata.getTotal() / metadata.getItemsPerPage());
log.info("Fetching {} pages of scores for account '{}'.", totalPages, profile.getAccountId()); log.info("Fetching {} pages of scores for account '{}'.", totalPages, profile.getAccountId());
for (int i = 2; i <= totalPages; i++) { for (int i = 2; i <= totalPages; i++) {
scores.add(getPageScores(profile, i)); scores.add(getPageScores(profile, i));
currentPageCallback.accept(new CurrentPageCallback(i, totalPages));
}
return scores;
} }
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> currentPageCallback) {
List<CachedPage> scores = getScores(account, currentPageCallback);
List<ScoreSaberScoreToken> 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()
);
} }
/** /**
@ -169,4 +208,45 @@ public class ScoreSaberService extends TextWebSocketHandler {
log.error("An error occurred while handling the message.", ex); 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;
}
} }