API: switch to Mongo for score tracking
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 39s

This commit is contained in:
Lee 2024-08-01 23:24:34 +01:00
parent f68fb48726
commit 8dfdc8c535
23 changed files with 614 additions and 580 deletions

@ -22,7 +22,7 @@ import java.util.Objects;
@EnableMongoRepositories(basePackages = "cc.fascinated.repository.mongo") @EnableMongoRepositories(basePackages = "cc.fascinated.repository.mongo")
@EnableScheduling @EnableScheduling
@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class) @SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)
@Log4j2(topic = "Ember") @Log4j2(topic = "Score Tracker")
public class Main { public class Main {
@SneakyThrows @SneakyThrows
public static void main(@NonNull String[] args) { public static void main(@NonNull String[] args) {

@ -0,0 +1,24 @@
package cc.fascinated.common;
import lombok.experimental.UtilityClass;
/**
* @author Fascinated (fascinated7)
*/
@UtilityClass
public class EnumUtils {
/**
* Gets the name of the enum
*
* @param e the enum
* @return the name
*/
public static String getEnumName(Enum<?> e) {
String[] split = e.name().split("_");
StringBuilder builder = new StringBuilder();
for (String s : split) {
builder.append(s.substring(0, 1).toUpperCase()).append(s.substring(1).toLowerCase()).append(" ");
}
return builder.toString().trim();
}
}

@ -2,7 +2,7 @@ package cc.fascinated.controller;
import cc.fascinated.exception.impl.BadRequestException; import cc.fascinated.exception.impl.BadRequestException;
import cc.fascinated.platform.Platform; import cc.fascinated.platform.Platform;
import cc.fascinated.services.TrackedScoreService; import cc.fascinated.services.ScoreService;
import lombok.NonNull; import lombok.NonNull;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
@ -20,11 +20,11 @@ public class ScoresController {
* The tracked score service to use. * The tracked score service to use.
*/ */
@NonNull @NonNull
private final TrackedScoreService trackedScoreService; private final ScoreService scoreService;
@Autowired @Autowired
public ScoresController(@NonNull TrackedScoreService trackedScoreService) { public ScoresController(@NonNull ScoreService scoreService) {
this.trackedScoreService = trackedScoreService; this.scoreService = scoreService;
} }
/** /**
@ -37,8 +37,8 @@ public class ScoresController {
*/ */
@ResponseBody @ResponseBody
@GetMapping(value = "/top/{platform}") @GetMapping(value = "/top/{platform}")
public ResponseEntity<List<?>> getTopScores(@PathVariable String platform) { public ResponseEntity<List<?>> getTopScores(@PathVariable String platform, @RequestParam(defaultValue = "false") boolean scoresonly) {
return ResponseEntity.ok(trackedScoreService.getTopScores(Platform.Platforms.getPlatform(platform), 50)); return ResponseEntity.ok(scoreService.getTopRankedScores(Platform.Platforms.getPlatform(platform), 50, scoresonly));
} }
/** /**
@ -52,20 +52,6 @@ public class ScoresController {
@ResponseBody @ResponseBody
@GetMapping(value = "/count/{platform}") @GetMapping(value = "/count/{platform}")
public ResponseEntity<?> getScoresCount(@PathVariable String platform) { public ResponseEntity<?> getScoresCount(@PathVariable String platform) {
return ResponseEntity.ok(trackedScoreService.getTotalScores(Platform.Platforms.getPlatform(platform))); return ResponseEntity.ok(scoreService.getTotalScores(Platform.Platforms.getPlatform(platform)));
}
/**
* A GET mapping to retrieve the total
* amount of scores over pp thresholds
*
* @param platform the platform to get the scores from
* @return the amount of scores
* @throws BadRequestException if there were no scores found
*/
@ResponseBody
@GetMapping(value = "/ppthresholds/{platform}")
public ResponseEntity<?> getScoresOver(@PathVariable String platform) {
return ResponseEntity.ok(trackedScoreService.getScoresOver(Platform.Platforms.getPlatform(platform)));
} }
} }

@ -0,0 +1,25 @@
package cc.fascinated.model;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.Id;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
@Setter
public class Counter {
/**
* The ID of the counter.
*/
@Id
private String id;
/**
* The next number in the counter.
*/
private long next;
}

@ -46,9 +46,9 @@ public class Leaderboard {
*/ */
private double stars; private double stars;
/** /**
* The cover image for this leaderboard. * The image of the song for this leaderboard.
*/ */
private String coverImage; private String image;
/** /**
* The difficulty of the song. * The difficulty of the song.

@ -0,0 +1,105 @@
package cc.fascinated.model.score;
import cc.fascinated.platform.Platform;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import java.util.Date;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
@Setter
@Document("scores")
public class Score {
/**
* The ID of the score.
* <p>
* This is an internal ID to avoid clashing with other scores.
* This is not the ID of the score on the platform.
* </p>
*/
@Id
@JsonIgnore
private final long id;
/**
* The ID of the player that set the score.
*/
@Indexed @JsonIgnore
private final String playerId;
/**
* The platform the score was set on.
* <p>
* eg: {@link Platform.Platforms#SCORESABER}
* </p>
*/
@Indexed
@JsonIgnore
private final Platform.Platforms platform;
/**
* The ID of the score of the platform it was set on.
*/
@Indexed
@JsonProperty("scoreId")
private final String platformScoreId;
/**
* The ID of the leaderboard the score was set on.
*/
@Indexed @JsonIgnore
private final String leaderboardId;
/**
* The rank of the score when it was set.
*/
private int rank;
/**
* The accuracy of the score in a percentage.
*/
private final double accuracy;
/**
* The PP of the score.
* <p>
* e.g. 500pp
* </p>
*/
private double pp;
/**
* The score of the score.
*/
private final int score;
/**
* The list of modifiers used in the score.
*/
private final String[] modifiers;
/**
* The number of misses in the score.
*/
private final int misses;
/**
* The number of bad cuts in the score.
*/
private final int badCuts;
/**
* The timestamp of when the score was set.
*/
private final Date timestamp;
}

@ -1,91 +0,0 @@
package cc.fascinated.model.score;
import cc.fascinated.model.leaderboard.Leaderboard;
import cc.fascinated.model.user.ScoreSaberAccount;
import cc.fascinated.model.user.User;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Date;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
public class ScoreResponse {
/**
* The ID of the score.
*/
private String scoreId;
/**
* The PP of the score.
*/
private Double pp;
/**
* The rank of the score.
*/
private Long rank;
/**
* The base score of the score.
*/
private Long score;
/**
* The modified score of the score.
*/
private Long modifiedScore;
/**
* The weight of the score.
*/
private Double weight;
/**
* The modifiers of the score.
*/
private String modifiers;
/**
* The multiplier of the score.
*/
private double multiplier;
/**
* The number of misses in the score.
*/
private Long missedNotes;
/**
* The number of bad cuts in the score.
*/
private Long badCuts;
/**
* The highest combo in the score.
*/
private Long maxCombo;
/**
* The accuracy of the score.
*/
private Double accuracy;
/**
* The user who set the score.
*/
private User user;
/**
* The ID of the leaderboard.
*/
private Leaderboard leaderboard;
/**
* The timestamp of the score.
*/
private Date timestamp;
}

@ -1,24 +0,0 @@
package cc.fascinated.model.score;
import java.util.HashMap;
import java.util.Map;
/**
* @author Fascinated (fascinated7)
*/
public class ScoresOverResponse {
/**
* Scores over a certain pp threshold.
*/
public Map<Integer, Integer> scoresOver = new HashMap<>();
/**
* Adds scores over a certain pp threshold.
*
* @param pp the pp threshold
* @param scoreAmount the amount of scores over the pp threshold
*/
public void addScores(int pp, int scoreAmount) {
scoresOver.put(pp, scoreAmount);
}
}

@ -1,99 +0,0 @@
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.
*/
@Id
private String scoreId;
/**
* The ID of the player who set the score.
*/
private String playerId;
/**
* The ID of the leaderboard.
*/
private String leaderboardId;
/**
* The PP of the score.
*/
private Double pp;
/**
* The rank of the score.
*/
private Long rank;
/**
* The base score of the score.
*/
private Long score;
/**
* The modified score of the score.
*/
private Long modifiedScore;
/**
* The weight of the score.
*/
private Double weight;
/**
* The modifiers of the score.
*/
private String modifiers;
/**
* The multiplier of the score.
*/
private double multiplier;
/**
* The number of misses in the score.
*/
private Long missedNotes;
/**
* The number of bad cuts in the score.
*/
private Long badCuts;
/**
* The highest combo in the score.
*/
private Long maxCombo;
/**
* The accuracy of the score.
*/
private Double accuracy;
/**
* The difficulty the score was set on.
*/
private String difficulty;
/**
* The timestamp of the score.
*/
private Date timestamp;
}

@ -0,0 +1,49 @@
package cc.fascinated.model.score.impl.scoresaber;
import cc.fascinated.model.score.Score;
import cc.fascinated.platform.Platform;
import lombok.Getter;
import java.util.Date;
/**
* @author Fascinated (fascinated7)
*/
@Getter
public class ScoreSaberScore extends Score {
/**
* The weight of the score.
*/
private final double weight;
/**
* The multiplier of the score.
*/
private final double multiplier;
/**
* The maximum combo achieved in the score.
*/
private final int maxCombo;
/**
* Gets the modified score.
*
* @return the modified score
*/
public int getModifiedScore() {
if (multiplier == 1) {
return getScore();
}
return (int) (getScore() * multiplier);
}
public ScoreSaberScore(long id, String playerId, Platform.Platforms platform, String platformScoreId, String leaderboardId, int rank,
double accuracy, double pp, int score, String[] modifiers, int misses, int badCuts, Date timestamp,
double weight, double multiplier, int maxCombo) {
super(id, playerId, platform, platformScoreId, leaderboardId, rank, accuracy, pp, score, modifiers, misses, badCuts, timestamp);
this.weight = weight;
this.multiplier = multiplier;
this.maxCombo = maxCombo;
}
}

@ -0,0 +1,33 @@
package cc.fascinated.model.score.impl.scoresaber;
import cc.fascinated.model.leaderboard.Leaderboard;
import cc.fascinated.model.user.User;
import cc.fascinated.platform.Platform;
import lombok.Getter;
import java.util.Date;
/**
* @author Fascinated (fascinated7)
*/
@Getter
public class ScoreSaberScoreResponse extends ScoreSaberScore {
/**
* The user that set the score.
*/
private final User user;
/**
* The leaderboard the score was set on.
*/
private final Leaderboard leaderboard;
public ScoreSaberScoreResponse(long id, String playerId, Platform.Platforms platform, String platformScoreId, String leaderboardId, int rank,
double accuracy, double pp, int score, String[] modifiers, int misses, int badCuts, Date timestamp,
double weight, double multiplier, int maxCombo, User user, Leaderboard leaderboard) {
super(id, playerId, platform, platformScoreId, leaderboardId, rank, accuracy, pp, score, modifiers, misses, badCuts,
timestamp, weight, multiplier, maxCombo);
this.user = user;
this.leaderboard = leaderboard;
}
}

@ -5,7 +5,6 @@ import cc.fascinated.model.token.ScoreSaberAccountToken;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Getter; import lombok.Getter;
import org.springframework.data.annotation.Transient;
import java.util.Date; import java.util.Date;

@ -1,6 +1,7 @@
package cc.fascinated.model.user; package cc.fascinated.model.user;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter; import lombok.Getter;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.Setter; import lombok.Setter;
@ -23,7 +24,7 @@ public class User {
/** /**
* The ID of the user. * The ID of the user.
*/ */
@Id @Id @JsonIgnore
private final UUID id; private final UUID id;
/** /**
@ -38,7 +39,7 @@ public class User {
/** /**
* The ID of the users steam profile. * The ID of the users steam profile.
*/ */
@Indexed(unique = true) @Indexed @JsonProperty("id")
private String steamId; private String steamId;
/** /**

@ -1,8 +1,8 @@
package cc.fascinated.platform.impl; package cc.fascinated.platform.impl;
import cc.fascinated.common.MathUtils; import cc.fascinated.common.MathUtils;
import cc.fascinated.model.score.Score;
import cc.fascinated.model.score.TotalScoresResponse; import cc.fascinated.model.score.TotalScoresResponse;
import cc.fascinated.model.score.TrackedScore;
import cc.fascinated.model.token.ScoreSaberAccountToken; import cc.fascinated.model.token.ScoreSaberAccountToken;
import cc.fascinated.model.token.ScoreSaberLeaderboardPageToken; import cc.fascinated.model.token.ScoreSaberLeaderboardPageToken;
import cc.fascinated.model.token.ScoreSaberLeaderboardToken; import cc.fascinated.model.token.ScoreSaberLeaderboardToken;
@ -11,12 +11,13 @@ import cc.fascinated.platform.CurvePoint;
import cc.fascinated.platform.Platform; import cc.fascinated.platform.Platform;
import cc.fascinated.services.QuestDBService; import cc.fascinated.services.QuestDBService;
import cc.fascinated.services.ScoreSaberService; import cc.fascinated.services.ScoreSaberService;
import cc.fascinated.services.TrackedScoreService; import cc.fascinated.services.ScoreService;
import cc.fascinated.services.UserService; import cc.fascinated.services.UserService;
import io.questdb.client.Sender; import io.questdb.client.Sender;
import lombok.NonNull; import lombok.NonNull;
import lombok.extern.log4j.Log4j2; import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.DependsOn;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.HashMap; import java.util.HashMap;
@ -27,13 +28,9 @@ import java.util.Map;
* @author Fascinated (fascinated7) * @author Fascinated (fascinated7)
*/ */
@Component @Component
@DependsOn("scoreService")
@Log4j2 @Log4j2
public class ScoreSaberPlatform extends Platform { public class ScoreSaberPlatform extends Platform {
/**
* Delay in ms for requests per minute.
*/
private static final long UPDATE_DELAY = 1000L / 250L; // 150 requests per minute
/** /**
* The base multiplier for stars. * The base multiplier for stars.
*/ */
@ -57,15 +54,9 @@ public class ScoreSaberPlatform extends Platform {
@NonNull @NonNull
private final QuestDBService questDBService; private final QuestDBService questDBService;
/**
* The tracked score service to use
*/
@NonNull
private final TrackedScoreService trackedScoreService;
@Autowired @Autowired
public ScoreSaberPlatform(@NonNull ScoreSaberService scoreSaberService, @NonNull UserService userService, @NonNull QuestDBService questDBService, public ScoreSaberPlatform(@NonNull ScoreSaberService scoreSaberService, @NonNull UserService userService, @NonNull QuestDBService questDBService,
@NonNull TrackedScoreService trackedScoreService) { @NonNull ScoreService scoreService) {
super(Platforms.SCORESABER, 1, Map.of( super(Platforms.SCORESABER, 1, Map.of(
1, new CurvePoint[]{ 1, new CurvePoint[]{
new CurvePoint(0, 0), new CurvePoint(0, 0),
@ -110,7 +101,6 @@ public class ScoreSaberPlatform extends Platform {
this.scoreSaberService = scoreSaberService; this.scoreSaberService = scoreSaberService;
this.userService = userService; this.userService = userService;
this.questDBService = questDBService; this.questDBService = questDBService;
this.trackedScoreService = trackedScoreService;
} }
/** /**
@ -134,7 +124,10 @@ public class ScoreSaberPlatform extends Platform {
CurvePoint point = curve[i]; CurvePoint point = curve[i];
CurvePoint nextPoint = curve[i + 1]; CurvePoint nextPoint = curve[i + 1];
if (accuracy >= point.getAcc() && accuracy <= nextPoint.getAcc()) { if (accuracy >= point.getAcc() && accuracy <= nextPoint.getAcc()) {
return MathUtils.lerp(point.getMultiplier(), nextPoint.getMultiplier(), (accuracy - point.getAcc()) / (nextPoint.getAcc() - point.getAcc())); return MathUtils.lerp(
point.getMultiplier(), nextPoint.getMultiplier(),
(accuracy - point.getAcc()) / (nextPoint.getAcc() - point.getAcc())
);
} }
} }
@ -157,7 +150,6 @@ public class ScoreSaberPlatform extends Platform {
continue; continue;
} }
ScoreSaberAccountToken account = scoreSaberService.getAccount(user); // Get the account from the ScoreSaber API ScoreSaberAccountToken account = scoreSaberService.getAccount(user); // Get the account from the ScoreSaber API
try (Sender sender = questDBService.getSender()) { try (Sender sender = questDBService.getSender()) {
sender.table("player") sender.table("player")
.symbol("platform", this.getPlatform().getPlatformName()) .symbol("platform", this.getPlatform().getPlatformName())
@ -178,7 +170,7 @@ public class ScoreSaberPlatform extends Platform {
@Override @Override
public void updateMetrics() { public void updateMetrics() {
try (Sender sender = questDBService.getSender()) { try (Sender sender = questDBService.getSender()) {
TotalScoresResponse totalScores = trackedScoreService.getTotalScores(this.getPlatform()); TotalScoresResponse totalScores = ScoreService.INSTANCE.getTotalScores(this.getPlatform());
sender.table("metrics") sender.table("metrics")
.symbol("platform", this.getPlatform().getPlatformName()) .symbol("platform", this.getPlatform().getPlatformName())
.longColumn("total_scores", totalScores.getTotalScores()) .longColumn("total_scores", totalScores.getTotalScores())
@ -189,8 +181,7 @@ public class ScoreSaberPlatform extends Platform {
@Override @Override
public void updateLeaderboards() { public void updateLeaderboards() {
// TODO: PUSH THIS List<Score> scores = ScoreService.INSTANCE.getRankedScores(this.getPlatform());
List<TrackedScore> scores = this.trackedScoreService.getTrackedScores(this.getPlatform(), true);
Map<String, ScoreSaberLeaderboardToken> leaderboards = new HashMap<>(); Map<String, ScoreSaberLeaderboardToken> leaderboards = new HashMap<>();
for (ScoreSaberLeaderboardPageToken rankedLeaderboard : this.scoreSaberService.getRankedLeaderboards()) { for (ScoreSaberLeaderboardPageToken rankedLeaderboard : this.scoreSaberService.getRankedLeaderboards()) {
for (ScoreSaberLeaderboardToken leaderboard : rankedLeaderboard.getLeaderboards()) { for (ScoreSaberLeaderboardToken leaderboard : rankedLeaderboard.getLeaderboards()) {
@ -199,7 +190,7 @@ public class ScoreSaberPlatform extends Platform {
} }
// Add any missing leaderboards // Add any missing leaderboards
for (TrackedScore score : scores) { for (Score score : scores) {
if (leaderboards.containsKey(score.getLeaderboardId())) { if (leaderboards.containsKey(score.getLeaderboardId())) {
continue; continue;
} }
@ -218,26 +209,16 @@ public class ScoreSaberPlatform extends Platform {
for (Map.Entry<String, ScoreSaberLeaderboardToken> leaderboardEntry : leaderboards.entrySet()) { for (Map.Entry<String, ScoreSaberLeaderboardToken> leaderboardEntry : leaderboards.entrySet()) {
String id = leaderboardEntry.getKey(); String id = leaderboardEntry.getKey();
ScoreSaberLeaderboardToken leaderboard = leaderboardEntry.getValue(); ScoreSaberLeaderboardToken leaderboard = leaderboardEntry.getValue();
if (finished > 0) {
// Sleep to prevent rate limiting
try {
Thread.sleep(UPDATE_DELAY);
} catch (InterruptedException e) {
log.error("Failed to sleep for rate limit reset", e);
}
}
try { try {
List<TrackedScore> toUpdate = scores.stream().filter(score -> { List<Score> toUpdate = scores.stream().filter(score -> {
if (!score.getLeaderboardId().equals(id)) { // Check if the leaderboard ID matches if (!score.getLeaderboardId().equals(id)) { // Check if the leaderboard ID matches
return false; return false;
} }
double pp = this.getPp(leaderboard.getStars(), score.getAccuracy()); double pp = this.getPp(leaderboard.getStars(), score.getAccuracy());
return pp != (score.getPp() == null ? 0D : score.getPp()); // Check if the pp has changed return pp != score.getPp(); // Check if the pp has changed
}).toList(); }).toList();
for (TrackedScore score : toUpdate) { // Update the scores for (Score score : toUpdate) { // Update the scores
if (leaderboard.getStars() == 0) { // The leaderboard was unranked if (leaderboard.getStars() == 0) { // The leaderboard was unranked
score.setPp(0D); score.setPp(0D);
} }
@ -246,7 +227,7 @@ public class ScoreSaberPlatform extends Platform {
} }
if (!toUpdate.isEmpty()) { // Save the scores if (!toUpdate.isEmpty()) { // Save the scores
this.trackedScoreService.updateScores(toUpdate.toArray(TrackedScore[]::new)); ScoreService.INSTANCE.updateScores(toUpdate.toArray(new Score[0]));
} }
finished++; finished++;

@ -1,90 +0,0 @@
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;
import java.util.List;
/**
* @author Fascinated (fascinated7)
*/
public interface TrackedScoreRepository extends CrudRepository<TrackedScore, String> {
/**
* Ensures that the deduplication of the scores is done.
*/
@Modifying @Transactional
@Query(value = "ALTER TABLE score DEDUP ENABLE UPSERT KEYS(timestamp, score_id)", nativeQuery = true)
void ensureDeduplication();
/**
* 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
*
* @param platform the platform to get the scores from
* @param amount the amount of scores to get
* @return the scores
*/
@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.
*
* @param platform the platform to get the scores from
* @return the total amount of scores for the platform
*/
@Query(value = "SELECT COUNT(*) FROM score WHERE platform = :platform", nativeQuery = true)
int countTotalScores(@Param("platform") String platform);
/**
* Gets the total amount of ranked scores
* for a platform.
*
* @param platform the platform to get the scores from
* @return the total amount of ranked scores for the platform
*/
@Query(value = "SELECT COUNT(*) FROM score WHERE platform = :platform AND pp > 0", nativeQuery = true)
int countTotalRankedScores(@Param("platform") String platform);
/**
* Gets all scores for a platform.
*
* @param platform the platform to get the scores from
* @return the scores
*/
@Query(value = "SELECT COUNT(*) FROM score WHERE platform = :platform AND pp > :pp", nativeQuery = true)
int getScoreCountOverPpThreshold(@Param("platform") String platform, @Param("pp") double pp);
}

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

@ -0,0 +1,78 @@
package cc.fascinated.repository.mongo;
import cc.fascinated.model.score.Score;
import cc.fascinated.platform.Platform;
import lombok.NonNull;
import org.springframework.data.mongodb.repository.Aggregation;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.repository.query.Param;
import java.util.List;
/**
* @author Fascinated (fascinated7)
*/
public interface ScoreRepository extends MongoRepository<Score, String> {
/**
* Gets the top ranked scores from the platform.
*
* @param platform the platform to get the scores from
* @param amount the amount of scores to get
* @return the scores
*/
@Aggregation(pipeline = {
"{ $match: { platform: ?0, pp: { $gt: 0 } } }",
"{ $sort: { pp: -1 } }",
"{ $limit: ?1 }"
})
List<Score> getTopRankedScores(@NonNull Platform.Platforms platform, int amount);
/**
* Gets all the ranked scores from the platform.
*
* @param platform the platform to get the scores from
* @return the scores
*/
@Aggregation(pipeline = {
"{ $match: { platform: ?0, pp: { $gt: 0 } } }",
"{ $sort: { pp: -1 } }"
})
List<Score> getRankedScores(@NonNull Platform.Platforms platform);
/**
* Updates a scores pp value.
*
* @param id The id of the score to update
* @param pp The new pp value
*/
@Aggregation(pipeline = {
"{ $match: { _id: ?0 } }",
"{ $set: { pp: ?1 } }"
})
void updateScorePP(@Param("id") long id, @Param("pp") double pp);
/**
* Gets the total scores for the platform.
*
* @param platform the platform to get the scores from
* @return the total scores
*/
@Aggregation(pipeline = {
"{ $match: { platform: ?0 } }",
"{ $count: 'total' }"
})
int getTotalScores(@NonNull Platform.Platforms platform);
/**
* Gets the total ranked scores for the platform.
*
* @param platform the platform to get the scores from
* @return the total ranked scores
*/
@Aggregation(pipeline = {
"{ $match: { platform: ?0, pp: { $gt: 0 } } }",
"{ $count: 'total' }"
})
int getTotalRankedScores(@NonNull Platform.Platforms platform);
}

@ -0,0 +1,54 @@
package cc.fascinated.services;
import cc.fascinated.model.Counter;
import cc.fascinated.repository.mongo.CounterRepository;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.Optional;
/**
* @author Fascinated (fascinated7)
*/
@Service
public class CounterService {
/**
* The counter repository to use.
*/
private final CounterRepository counterRepository;
@Autowired
public CounterService(CounterRepository counterRepository) {
this.counterRepository = counterRepository;
}
/**
* Gets the next number in the sequence.
*
* @param type The type of the counter.
* @return The next number in the sequence.
*/
public long getNext(CounterType type) {
Optional<Counter> counterOptional = counterRepository.findById(type.getId());
Counter counter = counterOptional.orElseGet(() -> new Counter(type.getId(), 1));
long current = counter.getNext();
counter.setNext(current + 1);
counterRepository.save(counter);
return current;
}
@AllArgsConstructor
@Getter
public enum CounterType {
SCORE("score");
/**
* The ID of the counter.
*/
private final String id;
}
}

@ -8,6 +8,7 @@ import lombok.extern.log4j.Log4j2;
import org.bson.Document; import org.bson.Document;
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.context.annotation.DependsOn;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -20,8 +21,10 @@ import java.util.concurrent.Executors;
* @author Fascinated (fascinated7) * @author Fascinated (fascinated7)
*/ */
@Service @Service
@DependsOn("mongoService")
@Log4j2(topic = "PlatformService") @Log4j2(topic = "PlatformService")
public class PlatformService { public class PlatformService {
public static PlatformService INSTANCE;
private static final ExecutorService EXECUTOR_SERVICE = Executors.newFixedThreadPool(1); private static final ExecutorService EXECUTOR_SERVICE = Executors.newFixedThreadPool(1);
/** /**
@ -31,6 +34,7 @@ public class PlatformService {
@Autowired @Autowired
public PlatformService(@NonNull ApplicationContext context) { public PlatformService(@NonNull ApplicationContext context) {
INSTANCE = this;
log.info("Registering platforms..."); log.info("Registering platforms...");
registerPlatform(context.getBean(ScoreSaberPlatform.class)); registerPlatform(context.getBean(ScoreSaberPlatform.class));
log.info("Loaded %s platforms.".formatted(this.platforms.size())); log.info("Loaded %s platforms.".formatted(this.platforms.size()));

@ -0,0 +1,196 @@
package cc.fascinated.services;
import cc.fascinated.common.DateUtils;
import cc.fascinated.common.EnumUtils;
import cc.fascinated.common.MathUtils;
import cc.fascinated.model.leaderboard.Leaderboard;
import cc.fascinated.model.score.Score;
import cc.fascinated.model.score.TotalScoresResponse;
import cc.fascinated.model.score.impl.scoresaber.ScoreSaberScore;
import cc.fascinated.model.score.impl.scoresaber.ScoreSaberScoreResponse;
import cc.fascinated.model.token.ScoreSaberLeaderboardToken;
import cc.fascinated.model.token.ScoreSaberPlayerScoreToken;
import cc.fascinated.model.token.ScoreSaberScoreToken;
import cc.fascinated.model.user.User;
import cc.fascinated.platform.Platform;
import cc.fascinated.repository.mongo.ScoreRepository;
import lombok.NonNull;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
/**
* @author Fascinated (fascinated7)
*/
@Service
@Log4j2(topic = "Score Service")
public class ScoreService {
public static ScoreService INSTANCE;
/**
* The counter service to use.
*/
@NonNull
private final CounterService counterService;
/**
* The user service to use.
*/
@NonNull
private final UserService userService;
/**
* The ScoreSaber service to use.
*/
@NonNull
private final ScoreSaberService scoreSaberService;
/**
* The score repository to use.
*/
@NonNull
private final ScoreRepository scoreRepository;
@Autowired
public ScoreService(@NonNull CounterService counterService, @NonNull UserService userService, @NonNull ScoreSaberService scoreSaberService,
@NonNull ScoreRepository scoreRepository) {
INSTANCE = this;
this.counterService = counterService;
this.userService = userService;
this.scoreSaberService = scoreSaberService;
this.scoreRepository = scoreRepository;
}
/**
* Gets the top ranked scores from the platform.
*
* @param platform The platform to get the scores from.
* @param amount The amount of scores to get.
* @param scoresOnly Whether to only get the scores.
* @return The scores.
*/
public List<ScoreSaberScoreResponse> getTopRankedScores(@NonNull Platform.Platforms platform, int amount, boolean scoresOnly) {
List<Score> trackedScores = scoreRepository.getTopRankedScores(platform, amount);
List<ScoreSaberScoreResponse> scores = new ArrayList<>();
for (Score score : trackedScores) {
ScoreSaberScore scoreSaberScore = (ScoreSaberScore) score;
User user = scoresOnly ? null : userService.getUser(score.getPlayerId());
Leaderboard leaderboard = scoresOnly ? null : Leaderboard.getFromScoreSaberToken(scoreSaberService.getLeaderboard(score.getLeaderboardId()));
scores.add(new ScoreSaberScoreResponse(
score.getId(),
score.getPlayerId(),
score.getPlatform(),
score.getPlatformScoreId(),
score.getLeaderboardId(),
score.getRank(),
score.getAccuracy(),
score.getPp(),
score.getScore(),
score.getModifiers(),
score.getMisses(),
score.getBadCuts(),
score.getTimestamp(),
scoreSaberScore.getWeight(),
scoreSaberScore.getMultiplier(),
scoreSaberScore.getMaxCombo(),
user,
leaderboard
));
}
return scores;
}
/**
* Gets all the ranked scores from the platform.
*
* @param platform The platform to get the scores from.
* @return The scores.
*/
public List<Score> getRankedScores(@NonNull Platform.Platforms platform) {
return scoreRepository.getRankedScores(platform);
}
/**
* Updates a scores pp value.
*
* @param scores The scores to update.
*/
public void updateScores(Score... scores) {
for (Score score : scores) {
scoreRepository.updateScorePP(score.getId(), score.getPp());
}
}
public TotalScoresResponse getTotalScores(Platform.Platforms platform) {
return new TotalScoresResponse(
scoreRepository.getTotalScores(platform),
scoreRepository.getTotalRankedScores(platform)
);
}
/**
* Tracks a ScoreSaber score.
*
* @param token The token of the score to track.
*/
public void trackScoreSaberScore(ScoreSaberPlayerScoreToken token) {
ScoreSaberLeaderboardToken leaderboard = this.scoreSaberService.getLeaderboard(token.getLeaderboard().getId());
ScoreSaberScoreToken score = token.getScore();
User user = userService.getUser(score.getLeaderboardPlayerInfo().getId());
double accuracy = leaderboard.getMaxScore() != 0 ? ((double) score.getBaseScore() / leaderboard.getMaxScore()) * 100 : 0;
if (accuracy == 0) {
log.warn("[Scoresaber] Leaderboard '{}' has a max score of 0, unable to calculate accuracy :(", leaderboard.getId());
}
double pp = score.getPp() != 0 ? PlatformService.INSTANCE.getScoreSaberPlatform().getPp(leaderboard.getStars(), accuracy) : 0; // Recalculate the pp
String[] modifiers = !score.getModifiers().isEmpty() ? score.getModifiers().split(",") : new String[0];
ScoreSaberScore scoreSaberScore = new ScoreSaberScore(
counterService.getNext(CounterService.CounterType.SCORE),
user.getSteamId(),
Platform.Platforms.SCORESABER,
score.getId(),
leaderboard.getId(),
score.getRank(),
accuracy,
pp,
score.getBaseScore(),
modifiers,
score.getMissedNotes(),
score.getBadCuts(),
DateUtils.getDateFromString(score.getTimeSet()),
score.getWeight(),
score.getMultiplier(),
score.getMaxCombo()
);
scoreRepository.save(scoreSaberScore);
this.logScore(Platform.Platforms.SCORESABER, scoreSaberScore, user);
}
/**
* Logs a score.
*
* @param platform The platform the score was tracked on.
* @param score The score to log.
* @param user The user who set the score.
*/
private void logScore(@NonNull Platform.Platforms platform, @NonNull Score score, @NonNull User user) {
String platformName = EnumUtils.getEnumName(platform);
boolean isRanked = score.getPp() != 0;
log.info("[{}] Tracked{} Score! id: {}, acc: {}%, {} score id: {},{} leaderboard: {}, player: {}",
platformName,
isRanked ? " Ranked" : "",
score.getId(),
MathUtils.format(score.getAccuracy(), 2),
platformName.toLowerCase(), score.getPlatformScoreId(),
isRanked ? " pp: %s,".formatted(score.getPp()) : "",
score.getLeaderboardId(),
user.getUsername() == null ? user.getSteamId() : user.getUsername()
);
}
}

@ -1,165 +0,0 @@
package cc.fascinated.services;
import cc.fascinated.exception.impl.BadRequestException;
import cc.fascinated.model.leaderboard.Leaderboard;
import cc.fascinated.model.score.ScoreResponse;
import cc.fascinated.model.score.ScoresOverResponse;
import cc.fascinated.model.score.TotalScoresResponse;
import cc.fascinated.model.score.TrackedScore;
import cc.fascinated.model.user.User;
import cc.fascinated.platform.Platform;
import cc.fascinated.repository.couchdb.TrackedScoreRepository;
import lombok.NonNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.LinkedList;
import java.util.List;
/**
* @author Fascinated (fascinated7)
*/
@Service
public class TrackedScoreService {
/**
* The scores over thresholds.
*/
private static final int[] SCORES_OVER = {1000, 900, 800, 700, 600, 500, 400, 300, 200, 100};
/**
* The tracked score repository to use.
*/
@NonNull
private final TrackedScoreRepository trackedScoreRepository;
/**
* The user service to use.
*/
@NonNull
private final UserService userService;
/**
* The ScoreSaber service to use.
*/
@NonNull
private final ScoreSaberService scoreSaberService;
@Autowired
public TrackedScoreService(@NonNull TrackedScoreRepository trackedScoreRepository, @NonNull UserService userService,
@NonNull ScoreSaberService scoreSaberService) {
this.trackedScoreRepository = trackedScoreRepository;
this.userService = userService;
this.scoreSaberService = scoreSaberService;
this.trackedScoreRepository.ensureDeduplication();
}
/**
* Gets a list of top tracked scores
* sorted by pp from the platform
*
* @param platform the platform to get the scores from
* @param amount the amount of scores to get
* @return the scores
*/
public List<ScoreResponse> getTopScores(Platform.Platforms platform, int amount) {
List<TrackedScore> foundScores = trackedScoreRepository.findTopRankedScores(platform.getPlatformName(), amount);
if (foundScores.isEmpty()) {
throw new BadRequestException("No scores found for platform " + platform.getPlatformName());
}
List<ScoreResponse> scores = new LinkedList<>();
for (TrackedScore trackedScore : foundScores) {
User user = this.userService.getUser(trackedScore.getPlayerId());
Leaderboard leaderboard = null;
switch (platform) {
case SCORESABER -> leaderboard = Leaderboard.getFromScoreSaberToken(this.scoreSaberService.getLeaderboard(trackedScore.getLeaderboardId()));
}
assert leaderboard != null; // This should never be null
scores.add(new ScoreResponse(
trackedScore.getScoreId(),
trackedScore.getPp(),
trackedScore.getRank(),
trackedScore.getScore(),
trackedScore.getModifiedScore(),
trackedScore.getWeight(),
trackedScore.getModifiers(),
trackedScore.getMultiplier(),
trackedScore.getMissedNotes(),
trackedScore.getBadCuts(),
trackedScore.getMaxCombo(),
trackedScore.getAccuracy(),
user,
leaderboard,
trackedScore.getTimestamp()
));
}
return scores;
}
/**
* Gets the amount of scores over pp thresholds.
*
* @param platform the platform to get the scores from
* @return the scores over pp thresholds
*/
public ScoresOverResponse getScoresOver(Platform.Platforms platform) {
ScoresOverResponse scoresOverResponse = new ScoresOverResponse();
for (int i : SCORES_OVER) {
scoresOverResponse.addScores(i, trackedScoreRepository.getScoreCountOverPpThreshold(platform.getPlatformName(), i));
}
return scoresOverResponse;
}
/**
* Gets the total amount of scores
* for a platform.
*
* @param platform the platform to get the scores from
* @return the total amount of scores for the platform
*/
public TotalScoresResponse getTotalScores(Platform.Platforms platform) {
return new TotalScoresResponse(
trackedScoreRepository.countTotalScores(platform.getPlatformName()),
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 onlyRanked) {
if (onlyRanked) {
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());
}
}
/**
* Deletes a list of tracked scores.
*
* @param scores the scores to delete
*/
public void deleteScores(TrackedScore... scores) {
for (TrackedScore score : scores) {
this.trackedScoreRepository.delete(score);
}
}
}

@ -74,7 +74,8 @@ public class UserService {
scoresaberAccount == null ? "now" : TimeUtils.format(System.currentTimeMillis() - scoresaberAccount.getLastUpdated().getTime()) scoresaberAccount == null ? "now" : TimeUtils.format(System.currentTimeMillis() - scoresaberAccount.getLastUpdated().getTime())
); );
ScoreSaberAccountToken accountToken = scoreSaberService.getAccount(user); ScoreSaberAccountToken accountToken = scoreSaberService.getAccount(user);
user.setScoresaberAccount(ScoreSaberAccount.getFromToken(accountToken)); user.setScoresaberAccount(ScoreSaberAccount.getFromToken(accountToken)); // Update the ScoreSaber account
user.setUsername(accountToken.getName()); // Update the username
this.saveUser(user); // Save the user this.saveUser(user); // Save the user
} }

@ -1,17 +1,12 @@
package cc.fascinated.websocket.impl; package cc.fascinated.websocket.impl;
import cc.fascinated.common.ScoreSaberUtils;
import cc.fascinated.model.token.ScoreSaberLeaderboardToken;
import cc.fascinated.model.token.ScoreSaberPlayerScoreToken; import cc.fascinated.model.token.ScoreSaberPlayerScoreToken;
import cc.fascinated.model.token.ScoreSaberScoreToken; import cc.fascinated.model.token.ScoreSaberScoreToken;
import cc.fascinated.model.token.ScoreSaberWebsocketDataToken; import cc.fascinated.model.token.ScoreSaberWebsocketDataToken;
import cc.fascinated.platform.Platform; import cc.fascinated.services.ScoreService;
import cc.fascinated.services.PlatformService;
import cc.fascinated.services.QuestDBService;
import cc.fascinated.services.UserService; import cc.fascinated.services.UserService;
import cc.fascinated.websocket.Websocket; import cc.fascinated.websocket.Websocket;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.questdb.client.Sender;
import lombok.NonNull; import lombok.NonNull;
import lombok.SneakyThrows; import lombok.SneakyThrows;
import lombok.extern.log4j.Log4j2; import lombok.extern.log4j.Log4j2;
@ -36,23 +31,16 @@ public class ScoreSaberWebsocket extends Websocket {
private final UserService userService; private final UserService userService;
/** /**
* The Influx service to use * The score service to use
*/ */
private final QuestDBService questDBService; private final ScoreService scoreService;
/**
* The platform service to use
*/
private final PlatformService platformService;
@Autowired @Autowired
public ScoreSaberWebsocket(@NonNull ObjectMapper objectMapper, @NonNull UserService userService, @NonNull QuestDBService questDBService, public ScoreSaberWebsocket(@NonNull ObjectMapper objectMapper, @NonNull UserService userService, @NonNull ScoreService scoreService) {
@NonNull PlatformService platformService) {
super("ScoreSaber", "wss://scoresaber.com/ws"); super("ScoreSaber", "wss://scoresaber.com/ws");
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.userService = userService; this.userService = userService;
this.questDBService = questDBService; this.scoreService = scoreService;
this.platformService = platformService;
} }
@Override @Override
@ -71,42 +59,12 @@ public class ScoreSaberWebsocket extends Websocket {
// Decode the message using Jackson // Decode the message using Jackson
ScoreSaberPlayerScoreToken scoreToken = this.objectMapper.readValue(response.getCommandData().toString(), ScoreSaberPlayerScoreToken.class); ScoreSaberPlayerScoreToken scoreToken = this.objectMapper.readValue(response.getCommandData().toString(), ScoreSaberPlayerScoreToken.class);
ScoreSaberScoreToken score = scoreToken.getScore(); ScoreSaberScoreToken score = scoreToken.getScore();
ScoreSaberLeaderboardToken leaderboard = scoreToken.getLeaderboard();
ScoreSaberScoreToken.LeaderboardPlayerInfo player = score.getLeaderboardPlayerInfo(); ScoreSaberScoreToken.LeaderboardPlayerInfo player = score.getLeaderboardPlayerInfo();
// Ensure the player is valid // Ensure the player is valid
if (!this.userService.isValidSteamId(player.getId())) { if (!this.userService.isValidSteamId(player.getId())) {
return; return;
} }
scoreService.trackScoreSaberScore(scoreToken);
userService.getUser(player.getId());
double accuracy = ((double) score.getBaseScore() / leaderboard.getMaxScore()) * 100;
String difficulty = ScoreSaberUtils.parseDifficulty(leaderboard.getDifficulty().getDifficulty());
double pp = platformService.getScoreSaberPlatform().getPp(leaderboard.getStars(), accuracy); // Recalculate the PP
try (Sender sender = questDBService.getSender()) {
sender.table("score")
.symbol("platform", Platform.Platforms.SCORESABER.getPlatformName())
// Player information
.symbol("player_id", player.getId())
// Score information
.symbol("leaderboard_id", leaderboard.getId())
.symbol("score_id", score.getId())
.doubleColumn("pp", pp)
.longColumn("rank", score.getRank())
.longColumn("score", score.getBaseScore())
.longColumn("modified_score", score.getModifiedScore())
.doubleColumn("weight", score.getWeight())
.stringColumn("modifiers", score.getModifiers())
.doubleColumn("multiplier", score.getMultiplier())
.longColumn("missed_notes", score.getMissedNotes())
.longColumn("bad_cuts", score.getBadCuts())
.longColumn("max_combo", score.getMaxCombo())
.doubleColumn("accuracy", accuracy)
.stringColumn("difficulty", difficulty)
.atNow();
}
log.info("Tracked score for {} with a score of {} and {}pp on {} with a rank of {}",
player.getId(), score.getBaseScore(), score.getPp(), leaderboard.getId(), score.getRank());
} }
} }