add support for changing curves
All checks were successful
Deploy to Dokku / docker (ubuntu-latest) (push) Successful in 1m20s

This commit is contained in:
Lee 2024-07-27 20:09:20 +01:00
parent ee626a21bc
commit 66b4b18793
12 changed files with 516 additions and 15 deletions

@ -0,0 +1,34 @@
package cc.fascinated.common;
import lombok.experimental.UtilityClass;
/**
* @author Fascinated (fascinated7)
*/
@UtilityClass
public class MathUtils {
/**
* 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);
}
}

@ -2,13 +2,19 @@ package cc.fascinated.model.score;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import java.util.Date;
/**
* @author Fascinated (fascinated7)
*/
@Entity
@Getter
@Setter
@Table(name = "score")
public class TrackedScore {
/**
* The ID of the score.
@ -86,6 +92,16 @@ public class TrackedScore {
*/
private String difficulty;
/**
* The star count of the difficulty.
*/
private Double stars;
/**
* The timestamp of the score.
*/
private Date timestamp;
/**
* Gets the Tracked Score as a DTO
*/

@ -2,14 +2,19 @@ package cc.fascinated.model.token;
import lombok.Getter;
import lombok.ToString;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
import java.util.List;
@Getter @ToString
@Getter
@ToString
@Document("scoresaber_leaderboard")
public class ScoreSaberLeaderboardToken {
/**
* The ID of the leaderboard.
*/
@Id
private String id;
/**

@ -0,0 +1,21 @@
package cc.fascinated.platform;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
public class CurvePoint {
/**
* The x value of the curve point.
*/
private final double x;
/**
* The y value of the curve point.
*/
private final double y;
}

@ -3,18 +3,61 @@ package cc.fascinated.platform;
import cc.fascinated.exception.impl.BadRequestException;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.Map;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@RequiredArgsConstructor
@Getter
@Setter
public abstract class Platform {
/**
* The name of the platform.
*/
private final Platforms platform;
/**
* The previous curve version for this
* platform in the database.
*/
private int previousCurveVersion;
/**
* The current curve version for getting
* pp from a star count.
*/
private final int currentCurveVersion;
/**
* The curve points for each curve version.
*/
private final Map<Integer, CurvePoint[]> curvePoints;
/**
* Checks the curve version to see if it exists.
*
* @param curveVersion the curve version to check
* @throws BadRequestException if the curve version does not exist
*/
public void checkCurveVersion(int curveVersion) {
if (!curvePoints.containsKey(curveVersion)) {
throw new BadRequestException("Curve version '%s' for platform '%s' was not found.".formatted(curveVersion, platform.getPlatformName()));
}
}
/**
* Gets the PP amount from the star count.
*
* @param stars the amount of stars
* @return the pp amount
*/
public abstract double getPp(int curveVersion, double stars, double accuracy);
/**
* Called every 10 minutes to update
* the players data in QuestDB.
@ -27,6 +70,12 @@ public abstract class Platform {
*/
public abstract void updateMetrics();
/**
* Called every day at midnight to update
* the leaderboards.
*/
public abstract void updateLeaderboards();
@AllArgsConstructor
@Getter
public enum Platforms {

@ -1,8 +1,12 @@
package cc.fascinated.platform.impl;
import cc.fascinated.common.MathUtils;
import cc.fascinated.model.score.TotalScoresResponse;
import cc.fascinated.model.score.TrackedScore;
import cc.fascinated.model.token.ScoreSaberAccountToken;
import cc.fascinated.model.token.ScoreSaberLeaderboardToken;
import cc.fascinated.model.user.User;
import cc.fascinated.platform.CurvePoint;
import cc.fascinated.platform.Platform;
import cc.fascinated.services.QuestDBService;
import cc.fascinated.services.ScoreSaberService;
@ -10,15 +14,35 @@ import cc.fascinated.services.TrackedScoreService;
import cc.fascinated.services.UserService;
import io.questdb.client.Sender;
import lombok.NonNull;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* @author Fascinated (fascinated7)
*/
@Component
@Log4j2
public class ScoreSaberPlatform extends Platform {
/**
* Delay in ms for requests per minute.
*/
private static final long UPDATE_DELAY = 1000L / 250L; // 250 requests per minute
/**
* The base multiplier for stars.
*/
private final double starMultiplier = 42.11;
/**
* The weight coefficient for the platform.
*/
private final double weightCoefficient = 0.965;
/**
* The ScoreSaber service to use
*/
@ -46,13 +70,80 @@ public class ScoreSaberPlatform extends Platform {
@Autowired
public ScoreSaberPlatform(@NonNull ScoreSaberService scoreSaberService, @NonNull UserService userService, @NonNull QuestDBService questDBService,
@NonNull TrackedScoreService trackedScoreService) {
super(Platforms.SCORESABER);
super(Platforms.SCORESABER, 1, Map.of(
1, new CurvePoint[]{
new CurvePoint(1.0, 5.367394282890631),
new CurvePoint(0.9995, 5.019543595874787),
new CurvePoint(0.999, 4.715470646416203),
new CurvePoint(0.99825, 4.325027383589547),
new CurvePoint(0.9975, 3.996793606763322),
new CurvePoint(0.99625, 3.5526145337555373),
new CurvePoint(0.995, 3.2022017597337955),
new CurvePoint(0.99375, 2.9190155639254955),
new CurvePoint(0.99125, 2.4902905794106913),
new CurvePoint(0.99, 2.324506282149922),
new CurvePoint(0.9875, 2.058947159052738),
new CurvePoint(0.985, 1.8563887693647105),
new CurvePoint(0.9825, 1.697536248647543),
new CurvePoint(0.98, 1.5702410055532239),
new CurvePoint(0.9775, 1.4664726399289512),
new CurvePoint(0.975, 1.3807102743105126),
new CurvePoint(0.9725, 1.3090333065057616),
new CurvePoint(0.97, 1.2485807759957321),
new CurvePoint(0.965, 1.1552120359501035),
new CurvePoint(0.96, 1.0871883573850478),
new CurvePoint(0.955, 1.0388633331418984),
new CurvePoint(0.95, 1.0),
new CurvePoint(0.94, 0.9417362980580238),
new CurvePoint(0.93, 0.9039994071865736),
new CurvePoint(0.92, 0.8728710341448851),
new CurvePoint(0.91, 0.8488375988124467),
new CurvePoint(0.9, 0.825756123560842),
new CurvePoint(0.875, 0.7816934560296046),
new CurvePoint(0.85, 0.7462290664143185),
new CurvePoint(0.825, 0.7150465663454271),
new CurvePoint(0.8, 0.6872268862950283),
new CurvePoint(0.75, 0.6451808210101443),
new CurvePoint(0.7, 0.6125565959114954),
new CurvePoint(0.65, 0.5866010012767576),
new CurvePoint(0.6, 0.18223233667439062),
new CurvePoint(0.0, 0.0)
}
));
this.scoreSaberService = scoreSaberService;
this.userService = userService;
this.questDBService = questDBService;
this.trackedScoreService = trackedScoreService;
}
/**
* Gets the modifier for the given accuracy.
*
* @param accuracy The accuracy.
* @return The modifier.
*/
public double getModifier(int curveVersion, double accuracy) {
accuracy = MathUtils.clamp(accuracy, 0, 100) / 100;
CurvePoint prev = this.getCurvePoints().get(curveVersion)[0];
for (CurvePoint point : this.getCurvePoints().get(curveVersion)) {
if (point.getX() >= accuracy) {
double distance = (prev.getX() - accuracy) / (prev.getX() - point.getX());
return MathUtils.lerp(prev.getY(), point.getY(), distance);
}
prev = point;
}
return 0;
}
@Override
public double getPp(int curveVersion, double stars, double accuracy) {
this.checkCurveVersion(curveVersion); // Check if the curve version exists
double pp = stars * this.starMultiplier;
double modifier = this.getModifier(curveVersion, accuracy);
return modifier * pp;
}
@Override
public void updatePlayers() {
for (User user : this.userService.getUsers()) {
@ -89,4 +180,56 @@ public class ScoreSaberPlatform extends Platform {
.atNow();
}
}
@Override
public void updateLeaderboards() {
List<TrackedScore> scores = this.trackedScoreService.getTrackedScores(this.getPlatform(), true);
List<String> leaderboardIds = scores.stream().map(TrackedScore::getLeaderboardId).toList();
log.info("Updating {} leaderboards for platform '{}'",
leaderboardIds.size(),
this.getPlatform().getPlatformName()
);
int finished = 0;
// Update the leaderboards
for (String id : leaderboardIds) {
try {
ScoreSaberLeaderboardToken leaderboard = this.scoreSaberService.getLeaderboard(id, true); // Update the cached leaderboard
if (leaderboard == null) {
log.warn("Failed to update leaderboard '{}' for platform '{}'", id, this.getPlatform().getPlatformName());
continue;
}
List<TrackedScore> toUpdate = scores.stream().filter(score -> score.getLeaderboardId().equals(id)).toList();
for (TrackedScore score : toUpdate) { // Update the scores
double pp = this.getPp(this.getCurrentCurveVersion(), leaderboard.getStars(), score.getAccuracy());
score.setPp(pp);
}
if (!toUpdate.isEmpty()) { // Save the scores
this.trackedScoreService.updateScores(toUpdate.toArray(TrackedScore[]::new));
}
finished++;
log.info("Updated leaderboard '{}' for platform '{}', changed {} scores. ({}/{})",
id,
this.getPlatform().getPlatformName(),
toUpdate.size(),
finished,
leaderboardIds.size()
);
// Sleep to prevent rate limiting
try {
Thread.sleep(UPDATE_DELAY);
} catch (InterruptedException e) {
e.printStackTrace();
}
} catch (Exception ex) {
log.error("An error occurred while updating leaderboard '{}'", id, ex);
}
}
}
}

@ -1,6 +1,8 @@
package cc.fascinated.repository.couchdb;
import cc.fascinated.model.score.TrackedScore;
import jakarta.transaction.Transactional;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;
@ -11,6 +13,16 @@ import java.util.List;
* @author Fascinated (fascinated7)
*/
public interface TrackedScoreRepository extends CrudRepository<TrackedScore, String> {
/**
* Updates the pp of a score.
*
* @param scoreId the ID of the score
* @param pp the new pp of the score
*/
@Modifying @Transactional
@Query(value = "UPDATE score SET pp = :pp WHERE score_id = :scoreId", nativeQuery = true)
void updateScorePp(@Param("scoreId") String scoreId, @Param("pp") double pp);
/**
* Gets a list of top tracked scores
* sorted by pp from the platform
@ -22,6 +34,24 @@ public interface TrackedScoreRepository extends CrudRepository<TrackedScore, Str
@Query(value = "SELECT * FROM score WHERE platform = :platform AND pp > 0 ORDER BY pp DESC LIMIT :amount", nativeQuery = true)
List<TrackedScore> findTopRankedScores(@Param("platform") String platform, @Param("amount") int amount);
/**
* Gets all tracked scores from a platform.
*
* @param platform the platform to get the scores from
* @return the scores
*/
@Query(value = "SELECT * FROM score WHERE platform = :platform", nativeQuery = true)
List<TrackedScore> findAllByPlatform(String platform);
/**
* Gets all tracked scores from a platform.
*
* @param platform the platform to get the scores from
* @return the scores
*/
@Query(value = "SELECT * FROM score WHERE platform = :platform AND pp > 0", nativeQuery = true)
List<TrackedScore> findAllByPlatformRankedOnly(String platform);
/**
* Gets the total amount of scores
* for a platform.

@ -0,0 +1,10 @@
package cc.fascinated.repository.mongo;
import cc.fascinated.model.token.ScoreSaberLeaderboardToken;
import org.springframework.data.mongodb.repository.MongoRepository;
/**
* @author Fascinated (fascinated7)
*/
public interface ScoreSaberLeaderboardRepository extends MongoRepository<ScoreSaberLeaderboardToken, String> {
}

@ -0,0 +1,31 @@
package cc.fascinated.services;
import com.mongodb.client.MongoCollection;
import org.bson.Document;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.stereotype.Service;
/**
* @author Fascinated (fascinated7)
*/
@Service
public class MongoService {
public static MongoService INSTANCE;
private final MongoTemplate mongoTemplate;
@Autowired
public MongoService(MongoTemplate mongo) {
INSTANCE = this;
this.mongoTemplate = mongo;
}
/**
* Get the platforms collection
*
* @return The platforms collection
*/
public MongoCollection<Document> getPlatformsCollection() {
return mongoTemplate.getCollection("platforms");
}
}

@ -1,9 +1,13 @@
package cc.fascinated.services;
import cc.fascinated.model.score.TrackedScore;
import cc.fascinated.platform.Platform;
import cc.fascinated.platform.impl.ScoreSaberPlatform;
import cc.fascinated.repository.couchdb.TrackedScoreRepository;
import com.mongodb.client.model.Filters;
import lombok.NonNull;
import lombok.extern.log4j.Log4j2;
import org.bson.Document;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.scheduling.annotation.Scheduled;
@ -11,6 +15,8 @@ import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* @author Fascinated (fascinated7)
@ -18,24 +24,74 @@ import java.util.List;
@Service
@Log4j2(topic = "PlatformService")
public class PlatformService {
private static final ExecutorService EXECUTOR_SERVICE = Executors.newFixedThreadPool(1);
/**
* The loaded platforms.
*/
private final List<Platform> platforms = new ArrayList<>();
/**
* The tracked score repository to use.
*/
@NonNull
private final TrackedScoreRepository trackedScoreRepository;
@Autowired
public PlatformService(@NonNull ApplicationContext context) {
public PlatformService(@NonNull ApplicationContext context, @NonNull TrackedScoreRepository trackedScoreRepository) {
this.trackedScoreRepository = trackedScoreRepository;
log.info("Registering platforms...");
registerPlatform(context.getBean(ScoreSaberPlatform.class));
log.info("Loaded %s platforms.".formatted(this.platforms.size()));
}
@Scheduled(cron = "0 */1 * * * *")
public void updatePlatforms() {
log.info("Updating %s platforms...".formatted(this.platforms.size()));
/**
* Updates the platform metrics.
* <p>
* This method is scheduled to run every minute.
* </p>
*/
@Scheduled(cron = "0 */5 * * * *")
public void updateMetrics() {
log.info("Updating %s platform metrics...".formatted(this.platforms.size()));
for (Platform platform : this.platforms) {
platform.updatePlayers();
platform.updateMetrics();
}
log.info("Finished updating platforms.");
log.info("Finished updating platform metrics.");
}
/**
* Updates the platform players.
* <p>
* This method is scheduled to run every 15 minutes.
* </p>
*/
@Scheduled(cron = "0 */15 * * * *")
public void updateScores() {
log.info("Updating %s platform players...".formatted(this.platforms.size()));
for (Platform platform : this.platforms) {
platform.updatePlayers();
}
log.info("Finished updating platform players.");
}
/**
* Refreshes the platform leaderboards.
* <p>
* This method is scheduled to run every day at midnight.
* This is to ensure that the leaderboards are up-to-date.
* </p>
*/
@Scheduled(cron = "0 0 0 * * *")
public void refreshPlatformLeaderboards() {
log.info("Refreshing platform leaderboards...");
EXECUTOR_SERVICE.execute(() -> {
for (Platform platform : this.platforms) {
platform.updateLeaderboards();
}
});
log.info("Finished refreshing platform leaderboards.");
}
/**
@ -45,6 +101,32 @@ public class PlatformService {
*/
public void registerPlatform(Platform platform) {
this.platforms.add(platform);
log.info(" - Registered platform '%s'".formatted(platform.getPlatform().getPlatformName()));
// Find the platform in the database
Document document = MongoService.INSTANCE.getPlatformsCollection().find(Filters.eq("_id", platform.getPlatform().getPlatformName())).first();
if (document == null) { // The platform was not found
document = new Document("_id", platform.getPlatform().getPlatformName());
MongoService.INSTANCE.getPlatformsCollection().insertOne(document);
}
// Set the previous curve version
platform.setPreviousCurveVersion(document.getInteger("currentCurveVersion", platform.getCurrentCurveVersion()));
// The curve was updated
if (platform.getPreviousCurveVersion() != platform.getCurrentCurveVersion()) {
log.info(" - Updated previous curve version for platform '%s' to '%s'".formatted(
platform.getPlatform().getPlatformName(),
platform.getPreviousCurveVersion()
));
log.info("Updating scores for platform '%s'...".formatted(platform.getPlatform().getPlatformName()));
EXECUTOR_SERVICE.execute(platform::updateLeaderboards); // Update the leaderboards
}
// Update the document
document.put("currentCurveVersion", platform.getCurrentCurveVersion());
MongoService.INSTANCE.getPlatformsCollection().replaceOne(Filters.eq("_id", platform.getPlatform().getPlatformName()), document);
}
}

@ -2,11 +2,17 @@ package cc.fascinated.services;
import cc.fascinated.exception.impl.BadRequestException;
import cc.fascinated.model.token.ScoreSaberAccountToken;
import cc.fascinated.model.token.ScoreSaberLeaderboardToken;
import cc.fascinated.model.user.User;
import cc.fascinated.repository.mongo.ScoreSaberLeaderboardRepository;
import kong.unirest.core.HttpResponse;
import kong.unirest.core.Unirest;
import lombok.NonNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.Optional;
/**
* @author Fascinated (fascinated7)
*/
@ -14,9 +20,21 @@ import org.springframework.stereotype.Service;
public class ScoreSaberService {
private static final String SCORESABER_API = "https://scoresaber.com/api/";
private static final String GET_PLAYER_ENDPOINT = SCORESABER_API + "player/%s/full";
private static final String GET_LEADERBOARD_ENDPOINT = SCORESABER_API + "leaderboard/by-id/%s/info";
/**
* Gets the ScoreSaber account for a user.
* The ScoreSaber leaderboard repository to use.
*/
@NonNull
private final ScoreSaberLeaderboardRepository leaderboardRepository;
@Autowired
public ScoreSaberService(@NonNull ScoreSaberLeaderboardRepository leaderboardRepository) {
this.leaderboardRepository = leaderboardRepository;
}
/**
* Gets the account for a user.
*
* @param user the user to get the account for
* @return the ScoreSaber account
@ -29,13 +47,49 @@ public class ScoreSaberService {
HttpResponse<ScoreSaberAccountToken> response = Unirest.get(GET_PLAYER_ENDPOINT.formatted(user.getSteamId()))
.asObject(ScoreSaberAccountToken.class);
if (response.getParsingError().isPresent()) { // Failed to parse the response
throw new BadRequestException("Failed to parse ScoreSaber account for %s".formatted(user.getUsername()));
throw new BadRequestException("Failed to parse ScoreSaber account for '%s'".formatted(user.getUsername()));
}
if (response.getStatus() != 200) { // The response was not successful
throw new BadRequestException("Failed to get ScoreSaber account for %s".formatted(user.getUsername()));
throw new BadRequestException("Failed to get ScoreSaber account for '%s'".formatted(user.getUsername()));
}
return response.getBody();
}
/**
* Gets a leaderboard for a leaderboard id.
*
* @param leaderboardId the leaderboard id to get the leaderboard for
* @return the ScoreSaber leaderboard
* @throws BadRequestException if an error occurred while getting the leaderboard
*/
public ScoreSaberLeaderboardToken getLeaderboard(String leaderboardId, boolean bypassCache) {
Optional<ScoreSaberLeaderboardToken> leaderboardOptional = leaderboardRepository.findById(leaderboardId);
if (leaderboardOptional.isPresent() && !bypassCache) { // The leaderboard is cached
return leaderboardOptional.get();
}
HttpResponse<ScoreSaberLeaderboardToken> response = Unirest.get(GET_LEADERBOARD_ENDPOINT.formatted(leaderboardId))
.asObject(ScoreSaberLeaderboardToken.class);
if (response.getParsingError().isPresent()) { // Failed to parse the response
throw new BadRequestException("Failed to parse ScoreSaber leaderboard for '%s'".formatted(leaderboardId));
}
if (response.getStatus() != 200) { // The response was not successful
throw new BadRequestException("Failed to get ScoreSaber leaderboard for '%s'".formatted(leaderboardId));
}
ScoreSaberLeaderboardToken leaderboard = response.getBody();
leaderboardRepository.save(leaderboard);
return leaderboard;
}
/**
* Gets a leaderboard for a leaderboard id.
*
* @param leaderboardId the leaderboard id to get the leaderboard for
* @return the ScoreSaber leaderboard
* @throws BadRequestException if an error occurred while getting the leaderboard
*/
public ScoreSaberLeaderboardToken getLeaderboard(String leaderboardId) {
return getLeaderboard(leaderboardId, false);
}
}

@ -26,7 +26,8 @@ public class TrackedScoreService {
/**
* The tracked score repository to use.
*/
@NonNull private final TrackedScoreRepository trackedScoreRepository;
@NonNull
private final TrackedScoreRepository trackedScoreRepository;
@Autowired
public TrackedScoreService(@NonNull TrackedScoreRepository trackedScoreRepository) {
@ -77,4 +78,29 @@ public class TrackedScoreService {
trackedScoreRepository.countTotalRankedScores(platform.getPlatformName())
);
}
/**
* Gets a list of tracked scores
* for a platform.
*
* @param platform the platform to get the scores from
* @return the tracked scores
*/
public List<TrackedScore> getTrackedScores(Platform.Platforms platform, boolean ranked) {
if (ranked) {
return trackedScoreRepository.findAllByPlatformRankedOnly(platform.getPlatformName());
}
return trackedScoreRepository.findAllByPlatform(platform.getPlatformName());
}
/**
* Saves a list of tracked scores.
*
* @param scores the scores to save
*/
public void updateScores(TrackedScore... scores) {
for (TrackedScore score : scores) {
this.trackedScoreRepository.updateScorePp(score.getScoreId(), score.getPp());
}
}
}