api: re-impl histories (muchhhhhhhhhh better now)
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 37s

This commit is contained in:
Lee 2024-08-05 03:57:59 +01:00
parent 29f5d5983a
commit 7b0c9f54ff
18 changed files with 441 additions and 144 deletions

@ -3,17 +3,36 @@ package cc.fascinated.common;
import lombok.experimental.UtilityClass;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.Locale;
/**
* @author Fascinated (fascinated7)
*/
@UtilityClass
public class DateUtils {
private static final ZoneId ZONE_ID = ZoneId.of("Europe/London");
private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_INSTANT
.withLocale(Locale.UK)
.withZone(ZONE_ID);
private static final DateTimeFormatter SIMPLE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd")
.withLocale(Locale.UK)
.withZone(ZONE_ID);
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_INSTANT;
/**
* Gets the date from a string.
*
* @param date The date string.
* @return The date.
*/
public static Date getDateFromIsoString(String date) {
return Date.from(Instant.from(ISO_FORMATTER.parse(date)));
}
/**
* Gets the date from a string.
@ -22,7 +41,19 @@ public class DateUtils {
* @return The date.
*/
public static Date getDateFromString(String date) {
return Date.from(Instant.from(FORMATTER.parse(date)));
LocalDate localDate = LocalDate.parse(date, SIMPLE_FORMATTER);
ZonedDateTime zonedDateTime = localDate.atStartOfDay(ZONE_ID);
return Date.from(zonedDateTime.toInstant());
}
/**
* Formats a date to a string.
*
* @param date The date to format.
* @return The formatted date.
*/
public String formatDate(Date date) {
return SIMPLE_FORMATTER.format(date.toInstant());
}
/**
@ -47,4 +78,13 @@ public class DateUtils {
public static Date getDaysAgo(int days) {
return Date.from(Instant.now().minus(days, ChronoUnit.DAYS));
}
/**
* Gets the date for midnight today.
*
* @return The date.
*/
public static Date getMidnightToday() {
return Date.from(Instant.now().truncatedTo(ChronoUnit.DAYS));
}
}

@ -17,7 +17,8 @@ public class UserController {
/**
* The user service to use
*/
@NonNull private final UserService userService;
@NonNull
private final UserService userService;
@Autowired
public UserController(@NonNull UserService userService) {
@ -36,4 +37,18 @@ public class UserController {
public ResponseEntity<UserDTO> getUser(@PathVariable String id) {
return ResponseEntity.ok(userService.getUser(id).getAsDTO());
}
/**
* A GET mapping to retrieve a user's statistic
* history using the users steam id.
*
* @param id the id of the user
* @return the user's statistic history
* @throws BadRequestException if the user is not found
*/
@ResponseBody
@GetMapping(value = "/histories/{id}")
public ResponseEntity<?> getUserHistories(@PathVariable String id) {
return ResponseEntity.ok(userService.getUser(id).getHistory().getPreviousHistories(30));
}
}

@ -0,0 +1,39 @@
package cc.fascinated.model.score;
import cc.fascinated.model.user.hmd.DeviceController;
import cc.fascinated.model.user.hmd.DeviceHeadset;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
public class DeviceInformation {
/**
* The headset that was used to set the score.
*/
private final DeviceHeadset headset;
/**
* The left controller that was used to set the score.
*/
private final DeviceController leftController;
/**
* The right controller that was used to set the score.
*/
private final DeviceController rightController;
/**
* Checks if the device information contains unknown values.
*
* @return if the device information contains unknown values
*/
public boolean containsUnknownDevices() {
return headset == DeviceHeadset.UNKNOWN
|| leftController == DeviceController.UNKNOWN
|| rightController == DeviceController.UNKNOWN;
}
}

@ -36,7 +36,8 @@ public class Score {
/**
* The ID of the player that set the score.
*/
@Indexed @JsonIgnore
@Indexed
@JsonIgnore
private final String playerId;
/**
@ -59,7 +60,8 @@ public class Score {
/**
* The ID of the leaderboard the score was set on.
*/
@Indexed @JsonIgnore
@Indexed
@JsonIgnore
private final String leaderboardId;
/**
@ -100,6 +102,14 @@ public class Score {
*/
private final Integer badCuts;
/**
* The device information that was used to set the score.
* <p>
* Headset and controllers information.
* </p>
*/
private final DeviceInformation deviceInformation;
/**
* The score history for map the score was set on.
*/
@ -155,4 +165,13 @@ public class Score {
public List<Score> getPreviousScores() {
return previousScores == null ? List.of() : previousScores;
}
/**
* Gets if the score is ranked.
*
* @return true if the score is ranked, false otherwise
*/
public boolean isRanked() {
return pp != null;
}
}

@ -1,5 +1,6 @@
package cc.fascinated.model.score.impl.scoresaber;
import cc.fascinated.model.score.DeviceInformation;
import cc.fascinated.model.score.Score;
import cc.fascinated.platform.Platform;
import lombok.Getter;
@ -28,10 +29,10 @@ public class ScoreSaberScore extends Score {
private final int maxCombo;
public ScoreSaberScore(long id, String playerId, Platform.Platforms platform, String platformScoreId, String leaderboardId, int rank,
double accuracy, Double pp, int score, String[] modifiers, Integer misses, Integer badCuts, List<Score> previousScores,
Date timestamp, Double weight, double multiplier, int maxCombo) {
double accuracy, Double pp, int score, String[] modifiers, Integer misses, Integer badCuts, DeviceInformation deviceInformation,
List<Score> previousScores, Date timestamp, Double weight, double multiplier, int maxCombo) {
super(id, playerId, platform, platformScoreId, leaderboardId, rank, accuracy, pp, score, modifiers, misses,
badCuts, previousScores, timestamp);
badCuts, deviceInformation, previousScores, timestamp);
this.weight = weight;
this.multiplier = multiplier;
this.maxCombo = maxCombo;

@ -1,6 +1,7 @@
package cc.fascinated.model.score.impl.scoresaber;
import cc.fascinated.model.leaderboard.Leaderboard;
import cc.fascinated.model.score.DeviceInformation;
import cc.fascinated.model.score.Score;
import cc.fascinated.model.user.UserDTO;
import cc.fascinated.platform.Platform;
@ -25,10 +26,11 @@ public class ScoreSaberScoreResponse extends ScoreSaberScore {
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, List<Score> previousScores,
Date timestamp, double weight, double multiplier, int maxCombo, UserDTO user, Leaderboard leaderboard) {
double accuracy, double pp, int score, String[] modifiers, int misses, int badCuts, DeviceInformation deviceInformation,
List<Score> previousScores, Date timestamp, double weight, double multiplier, int maxCombo, UserDTO user,
Leaderboard leaderboard) {
super(id, playerId, platform, platformScoreId, leaderboardId, rank, accuracy, pp, score, modifiers, misses, badCuts,
previousScores, timestamp, weight, multiplier, maxCombo);
deviceInformation, previousScores, timestamp, weight, multiplier, maxCombo);
this.user = user;
this.leaderboard = leaderboard;
}

@ -56,7 +56,7 @@ public class ScoreSaberAccount {
token.getCountry(),
token.getRank(),
token.getCountryRank(),
DateUtils.getDateFromString(token.getFirstSeen()),
DateUtils.getDateFromIsoString(token.getFirstSeen()),
new Date()
);
}

@ -1,18 +1,17 @@
package cc.fascinated.model.user;
import cc.fascinated.model.user.statistic.Statistic;
import cc.fascinated.platform.Platform;
import cc.fascinated.model.user.history.History;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.*;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.ToString;
import lombok.extern.log4j.Log4j2;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
@ -21,11 +20,10 @@ import java.util.UUID;
@RequiredArgsConstructor
@Getter
@Setter
@Log4j2
@ToString
@Document("user")
public class User {
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("EEE MMM d HH:mm:ss zzz yyyy");
/**
* The ID of the user.
*/
@ -67,55 +65,16 @@ public class User {
/**
* The user's statistic history.
*/
public Map<Platform.Platforms, Map<String, Statistic>> histories;
public History history;
/**
* The user's history points history.
* Gets the user's statistic history
*/
@JsonIgnore
public Map<Platform.Platforms, Map<Date, Statistic>> getHistories() {
if (this.histories == null) {
this.histories = new HashMap<>();
public History getHistory() {
if (this.history == null) {
this.history = new History();
}
Map<Platform.Platforms, Map<Date, Statistic>> toReturn = new HashMap<>();
for (Platform.Platforms platform : histories.keySet()) {
toReturn.put(platform, getHistory(platform));
}
return toReturn;
}
/**
* Gets the history points for a platform.
*
* @param platform the platform to get the statistics for
* @return the statistics
*/
@SneakyThrows
public Map<Date, Statistic> getHistory(@NonNull Platform.Platforms platform) {
if (this.histories == null) {
this.histories = new HashMap<>();
}
Map<String, Statistic> statisticMap = this.histories.computeIfAbsent(platform, k -> new HashMap<>());
Map<Date, Statistic> statistics = new HashMap<>();
for (Map.Entry<String, Statistic> entry : statisticMap.entrySet()) {
statistics.put(DATE_FORMAT.parse(entry.getKey()), entry.getValue());
}
return statistics;
}
/**
* Adds a history point to the user's history.
*
* @param platform the platform to add the statistic for
* @param date the date of the statistic
* @param statistic the statistic to add
*/
public void addHistory(@NonNull Platform.Platforms platform, @NonNull Date date, @NonNull Statistic statistic) {
if (this.histories == null) {
this.histories = new HashMap<>();
}
Map<String, Statistic> statisticMap = this.histories.computeIfAbsent(platform, k -> new HashMap<>());
statisticMap.put(String.valueOf(date.toString()), statistic);
return this.history;
}
/**

@ -0,0 +1,78 @@
package cc.fascinated.model.user.history;
import cc.fascinated.common.DateUtils;
import com.fasterxml.jackson.annotation.JsonIgnore;
import java.util.*;
/**
* @author Fascinated (fascinated7)
*/
public class History {
/**
* The user's history points in time.
*/
private Map<String, HistoryPoint> histories;
/**
* The user's history points history.
*/
@JsonIgnore
public Map<Date, HistoryPoint> getHistories() {
if (this.histories == null) {
this.histories = new HashMap<>();
}
Map<Date, HistoryPoint> toReturn = new HashMap<>();
this.histories.forEach((key, value) -> toReturn.put(DateUtils.getDateFromString(key), value));
return toReturn;
}
/**
* Gets the user's history for today.
*
* @return the user's history for today
*/
@JsonIgnore
public HistoryPoint getTodayHistory() {
if (this.histories == null) {
this.histories = new HashMap<>();
}
Date midnight = DateUtils.getMidnightToday();
return this.histories.computeIfAbsent(DateUtils.formatDate(midnight), key -> new HistoryPoint());
}
/**
* Gets the user's history for a specific date.
*
* @param date the date to get the history for
* @return the user's history for the date
*/
public HistoryPoint getHistoryForDate(Date date) {
if (this.histories == null) {
this.histories = new HashMap<>();
}
return this.histories.get(DateUtils.formatDate(date));
}
/**
* Gets the user's HistoryPoint history for
* an amount of days ago.
*
* @param days the amount of days ago
* @return the user's HistoryPoint history
*/
public TreeMap<String, HistoryPoint> getPreviousHistories(int days) {
Date date = DateUtils.getDaysAgo(days);
Map<String, HistoryPoint> toReturn = new HashMap<>();
for (Map.Entry<Date, HistoryPoint> history : getHistories().entrySet()) {
if (history.getKey().after(date)) {
toReturn.put(DateUtils.formatDate(history.getKey()), history.getValue());
}
}
// Sort the history by date (newest > oldest)
TreeMap<String, HistoryPoint> sorted = new TreeMap<>(Comparator.comparing(DateUtils::getDateFromString).reversed());
sorted.putAll(toReturn);
return sorted;
}
}

@ -0,0 +1,60 @@
package cc.fascinated.model.user.history;
import lombok.Getter;
import lombok.Setter;
/**
* @author Fascinated (fascinated7)
*/
@Getter
@Setter
public class HistoryPoint {
/**
* The rank of the player.
*/
private Integer rank;
/**
* The pp of the player.
*/
private Integer countryRank;
/**
* The pp of the player.
*/
private Double pp;
/**
* Play count of all the player's scores.
*/
private Integer totalPlayCount;
/**
* Play count of all the player's ranked scores.
*/
private Integer totalRankedPlayCount;
/**
* Play count for this day's unranked scores.
*/
private Integer unrankedPlayCount = 0;
/**
* Play count for this day's ranked scores.
*/
private Integer rankedPlayCount = 0;
/**
* Increment the total ranked play count for this day.
*/
public void incrementRankedPlayCount() {
rankedPlayCount++;
}
/**
* Increment the total unranked play count for this day.
*/
public void incrementUnrankedPlayCount() {
unrankedPlayCount++;
}
}

@ -0,0 +1,50 @@
package cc.fascinated.model.user.hmd;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
public enum DeviceController {
UNKNOWN("Unknown"),
/**
* Oculus Controllers
*/
OCULUS_QUEST_TOUCH("Touch"),
OCULUS_QUEST_2_TOUCH("Quest 2 Touch"),
OCULUS_QUEST_3_TOUCH("Quest 3 Touch"),
/**
* HP Controllers
*/
HP_REVERB("HP Reverb"),
/**
* Valve Controllers
*/
VALVE_KNUCKLES("Knuckles");
/**
* The controller name
*/
private final String name;
/**
* Gets a controller by its name.
*
* @param name the name of the controller
* @return the controller
*/
public static DeviceController getByName(String name) {
for (DeviceController deviceController : values()) {
if (deviceController.getName().equalsIgnoreCase(name)) {
return deviceController;
}
}
return null;
}
}

@ -0,0 +1,57 @@
package cc.fascinated.model.user.hmd;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
public enum DeviceHeadset {
UNKNOWN("Unknown"),
/**
* Oculus HMDs
*/
OCULUS_CV1("Rift"),
OCULUS_QUEST("Quest"),
OCULUS_QUEST_2("Quest 2"),
OCULUS_QUEST_3("Quest 3"),
OCULUS_RIFT_S("Rift S"),
/**
* HTC HMDs
*/
HTC_VIVE("Vive"),
/**
* HP HMDs
*/
HP_REVERB("HP Reverb"),
/**
* Valve HMDs
*/
VALVE_INDEX("Valve Index");
/**
* The name of the headset.
*/
private final String name;
/**
* Gets a headset by its name.
*
* @param name the name of the headset
* @return the headset
*/
public static DeviceHeadset getByName(String name) {
for (DeviceHeadset deviceHeadset : values()) {
if (deviceHeadset.getName().equalsIgnoreCase(name)) {
return deviceHeadset;
}
}
return null;
}
}

@ -1,23 +0,0 @@
package cc.fascinated.model.user.statistic;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.data.mongodb.core.mapping.Document;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
@Document("player_histories")
public class Statistic {
/**
* The rank of the player.
*/
private final int rank;
/**
* The pp of the player.
*/
private final int countryRank;
}

@ -1,38 +0,0 @@
package cc.fascinated.model.user.statistic.impl;
import cc.fascinated.model.user.statistic.Statistic;
import lombok.Getter;
/**
* @author Fascinated (fascinated7)
*/
@Getter
public class ScoreSaberStatistic extends Statistic {
/**
* The total score of the player.
*/
private final long totalScore;
/**
* The total ranked score of the player.
*/
private final long totalRankedScore;
/**
* The average ranked accuracy of the player.
*/
private final double averageRankedAccuracy;
/**
* The total play count of the player.
*/
private final int totalPlayCount;
public ScoreSaberStatistic(int rank, int countryRank, long totalScore, long totalRankedScore, double averageRankedAccuracy, int totalPlayCount) {
super(rank, countryRank);
this.totalScore = totalScore;
this.totalRankedScore = totalRankedScore;
this.averageRankedAccuracy = averageRankedAccuracy;
this.totalPlayCount = totalPlayCount;
}
}

@ -72,7 +72,7 @@ public abstract class Platform {
* Called to update the players
* data in QuestDB.
*/
public abstract void updatePlayers();
public abstract void trackPlayerMetrics();
/**
* Called to update the metrics

@ -7,7 +7,7 @@ import cc.fascinated.model.token.ScoreSaberAccountToken;
import cc.fascinated.model.token.ScoreSaberLeaderboardPageToken;
import cc.fascinated.model.token.ScoreSaberLeaderboardToken;
import cc.fascinated.model.user.User;
import cc.fascinated.model.user.statistic.impl.ScoreSaberStatistic;
import cc.fascinated.model.user.history.HistoryPoint;
import cc.fascinated.platform.CurvePoint;
import cc.fascinated.platform.Platform;
import cc.fascinated.services.ScoreSaberService;
@ -137,21 +137,19 @@ public class ScoreSaberPlatform extends Platform {
}
@Override
public void updatePlayers() {
Date date = DateUtils.alignToCurrentHour(new Date());
public void trackPlayerMetrics() {
Date date = DateUtils.getMidnightToday();
for (User user : this.userService.getUsers(false)) {
if (!user.isLinkedAccount()) { // Check if the user has linked their account
continue;
}
ScoreSaberAccountToken account = scoreSaberService.getAccount(user); // Get the account from the ScoreSaber API
user.addHistory(this.getPlatform(), date, new ScoreSaberStatistic(
account.getRank(),
account.getCountryRank(),
account.getScoreStats().getTotalScore(),
account.getScoreStats().getTotalRankedScore(),
account.getScoreStats().getAverageRankedAccuracy(),
account.getScoreStats().getTotalPlayCount()
)); // Add the statistic to the user's history
HistoryPoint history = user.getHistory().getHistoryForDate(date);
history.setPp(account.getPp());
history.setRank(account.getRank());
history.setCountryRank(account.getCountryRank());
history.setTotalPlayCount(account.getScoreStats().getTotalPlayCount());
history.setTotalRankedPlayCount((int) account.getScoreStats().getTotalRankedScore());
this.userService.saveUser(user); // Save the user
}
}

@ -43,7 +43,7 @@ public class PlatformService {
/**
* Updates the platform metrics.
* <p>
* This method is scheduled to run every minute.
* This method is scheduled to run every 5 minutes.
* </p>
*/
@Scheduled(cron = "0 */5 * * * *")
@ -65,7 +65,7 @@ public class PlatformService {
public void updatePlayerMetrics() {
log.info("Updating %s platform player metrics...".formatted(this.platforms.size()));
for (Platform platform : this.platforms) {
platform.updatePlayers();
platform.trackPlayerMetrics();
}
log.info("Finished updating platform player metrics.");
}

@ -5,6 +5,7 @@ import cc.fascinated.common.EnumUtils;
import cc.fascinated.common.MathUtils;
import cc.fascinated.common.Tuple;
import cc.fascinated.model.leaderboard.Leaderboard;
import cc.fascinated.model.score.DeviceInformation;
import cc.fascinated.model.score.Score;
import cc.fascinated.model.score.TotalScoresResponse;
import cc.fascinated.model.score.impl.scoresaber.ScoreSaberScore;
@ -14,6 +15,9 @@ import cc.fascinated.model.token.ScoreSaberPlayerScoreToken;
import cc.fascinated.model.token.ScoreSaberScoreToken;
import cc.fascinated.model.user.User;
import cc.fascinated.model.user.UserDTO;
import cc.fascinated.model.user.history.HistoryPoint;
import cc.fascinated.model.user.hmd.DeviceController;
import cc.fascinated.model.user.hmd.DeviceHeadset;
import cc.fascinated.platform.Platform;
import cc.fascinated.repository.ScoreRepository;
import lombok.NonNull;
@ -96,6 +100,7 @@ public class ScoreService {
score.getModifiers(),
score.getMisses(),
score.getBadCuts(),
score.getDeviceInformation(),
score.getPreviousScores(),
score.getTimestamp(),
scoreSaberScore.getWeight(),
@ -130,6 +135,12 @@ public class ScoreService {
}
}
/**
* Gets the total scores for the platform.
*
* @param platform The platform to get the scores from.
* @return The total scores.
*/
public TotalScoresResponse getTotalScores(Platform.Platforms platform) {
return new TotalScoresResponse(
scoreRepository.getTotalScores(platform),
@ -179,15 +190,27 @@ public class ScoreService {
modifiers.length == 0 ? null : modifiers, // no modifiers, set to null to save data
score.getMissedNotes() == 0 ? null : score.getMissedNotes(), // no misses, set to null to save data
score.getBadCuts() == 0 ? null : score.getBadCuts(), // no bad cuts, set to null to save data
new DeviceInformation(
score.getDeviceHmd() == null ? DeviceHeadset.UNKNOWN : DeviceHeadset.getByName(score.getDeviceHmd()),
score.getDeviceControllerLeft() == null ? DeviceController.UNKNOWN : DeviceController.getByName(score.getDeviceControllerLeft()),
score.getDeviceControllerRight() == null ? DeviceController.UNKNOWN : DeviceController.getByName(score.getDeviceControllerRight())
),
previousScores,
DateUtils.getDateFromString(score.getTimeSet()),
DateUtils.getDateFromIsoString(score.getTimeSet()),
score.getWeight() == 0 ? null : score.getWeight(), // no weight, set to null to save data
score.getMultiplier(),
score.getMaxCombo()
);
scoreRepository.save(scoreSaberScore);
this.saveScore(user, scoreSaberScore);
this.logScore(Platform.Platforms.SCORESABER, Leaderboard.getFromScoreSaberToken(leaderboard), scoreSaberScore, user,
previousScoreExists && previousScore.getScore() < scoreSaberScore.getScore());
if (scoreSaberScore.getDeviceInformation().containsUnknownDevices()) {
log.warn(" - Score contains unknown device: hmd: {}, controller left: {}, controller right: {}",
score.getDeviceHmd(),
score.getDeviceControllerLeft(),
score.getDeviceControllerRight()
);
}
}
/**
@ -239,6 +262,22 @@ public class ScoreService {
return scoreRepository.getBestImprovedScores(platform, DateUtils.getDaysAgo(days));
}
/**
* Saves a score.
*
* @param score The score to save.
*/
public void saveScore(User user, Score score) {
HistoryPoint todayHistory = user.getHistory().getTodayHistory();
if (score.isRanked()) {
todayHistory.incrementRankedPlayCount();
} else {
todayHistory.incrementUnrankedPlayCount();
}
userService.saveUser(user); // Save the user
scoreRepository.save(score); // Save the score
}
/**
* Logs a score.
*
@ -250,6 +289,7 @@ public class ScoreService {
@NonNull User user, boolean improvedScore) {
String platformName = EnumUtils.getEnumName(platform);
boolean isRanked = score.getPp() != 0;
log.info("[{}] {}Tracked{} Score! id: {}, acc: {}%, {} score id: {},{} leaderboard: {}, difficulty: {}, player: {} ({})",
platformName,
improvedScore ? "Improved " : "",