diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/user/User.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/user/User.java index c5b1b5fe..2301347f 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/user/User.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/user/User.java @@ -98,6 +98,9 @@ public class User extends BaseGitServiceEntity { @ToString.Exclude private Set reviewComments = new HashSet<>(); + // Current ranking points for the leaderboard leagues + private int leaguePoints; + public enum Type { USER, ORGANIZATION, BOT } diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/user/UserRepository.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/user/UserRepository.java index 72567beb..3da3833f 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/user/UserRepository.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/gitprovider/user/UserRepository.java @@ -17,6 +17,14 @@ public interface UserRepository extends JpaRepository { """) Optional findByLogin(@Param("login") String login); + @Query(""" + SELECT u + FROM User u + LEFT JOIN FETCH u.mergedPullRequests + WHERE u.login = :login + """) + Optional findByLoginWithEagerMergedPullRequests(@Param("login") String login); + @Query(""" SELECT u FROM User u diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/leaderboard/LeaderboardTaskScheduler.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/leaderboard/LeaderboardTaskScheduler.java index 4577da5c..f24e9495 100644 --- a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/leaderboard/LeaderboardTaskScheduler.java +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/leaderboard/LeaderboardTaskScheduler.java @@ -1,5 +1,6 @@ package de.tum.in.www1.hephaestus.leaderboard; +import de.tum.in.www1.hephaestus.leaderboard.tasks.LeaguePointsUpdateTask; import de.tum.in.www1.hephaestus.leaderboard.tasks.SlackWeeklyLeaderboardTask; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -41,6 +42,9 @@ public class LeaderboardTaskScheduler { @Autowired private SlackWeeklyLeaderboardTask slackWeeklyLeaderboardTask; + @Autowired + private LeaguePointsUpdateTask leaguePointsUpdateTask; + @EventListener(ApplicationReadyEvent.class) public void activateTaskScheduler() { @@ -61,7 +65,7 @@ public void activateTaskScheduler() { } scheduleSlackMessage(cron); - + scheduleLeaguePointsUpdate(cron); } /** @@ -78,4 +82,9 @@ private void scheduleSlackMessage(String cron) { logger.info("Scheduling Slack message to run with {}", cron); taskScheduler.schedule(slackWeeklyLeaderboardTask, new CronTrigger(cron)); } + + private void scheduleLeaguePointsUpdate(String cron) { + logger.info("Scheduling league points update to run with {}", cron); + taskScheduler.schedule(leaguePointsUpdateTask, new CronTrigger(cron)); + } } diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/leaderboard/LeaguePointsCalculationService.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/leaderboard/LeaguePointsCalculationService.java new file mode 100644 index 00000000..84d98fdc --- /dev/null +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/leaderboard/LeaguePointsCalculationService.java @@ -0,0 +1,119 @@ +package de.tum.in.www1.hephaestus.leaderboard; + +import de.tum.in.www1.hephaestus.gitprovider.pullrequest.PullRequest; +import de.tum.in.www1.hephaestus.gitprovider.user.User; +import java.time.OffsetDateTime; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +@Service +public class LeaguePointsCalculationService { + private final Logger logger = LoggerFactory.getLogger(LeaguePointsCalculationService.class); + + // Starting points for new players + public static int POINTS_DEFAULT = 1000; + // Upper bound for first reducting in the k-factor + public static int POINTS_THRESHOLD_HIGH = 1750; + // Lower bound for first reducting in the k-factor + public static int POINTS_THRESHOLD_LOW = 1250; + // Minimum amount of points to decay each cycle + public static int DECAY_MINIMUM = 10; + // Factor to determine how much of the current points are decayed each cycle + public static double DECAY_FACTOR = 0.05; + // K-factors depending on the player's league points + public static double K_FACTOR_NEW_PLAYER = 2.0; + public static double K_FACTOR_LOW_POINTS = 1.5; + public static double K_FACTOR_MEDIUM_POINTS = 1.2; + public static double K_FACTOR_HIGH_POINTS = 1.1; + + + + public int calculateNewPoints(User user, LeaderboardEntryDTO entry) { + // Initialize points for new players + if (user.getLeaguePoints() == 0) { + user.setLeaguePoints(POINTS_DEFAULT); + } + + int oldPoints = user.getLeaguePoints(); + + double kFactor = getKFactor(user); + // Base decay + int decay = calculateDecay(user.getLeaguePoints()); + // Bonus based on leaderboard score + int performanceBonus = calculatePerformanceBonus(entry.score()); + // Additional bonus for placements + int placementBonus = calculatePlacementBonus(entry.rank()); + // Calculate final point change + int pointChange = (int) (kFactor * (performanceBonus + placementBonus - decay)); + // Apply minimum change to prevent extreme swings + int newPoints = Math.max(1, oldPoints + pointChange); + + logger.info("Points calculation: old={}, k={}, decay={}, performanceBonus={}, placement={}, pointchange={}, new={}", + oldPoints, kFactor, decay, performanceBonus, placementBonus, pointChange, newPoints); + + return newPoints; + } + + /** + * Calculate the K factor for the user based on their current points and if they are a new player. + * The K-factor is used to control the sensitivity of the rating system to changes in the leaderboard. + * New players have a higher K-factor to allow them to quickly reach their true skill level. + * @param user + * @return K factor + * @see Wikipedia: Most accurate K-factor + */ + private double getKFactor(User user) { + if (isNewPlayer(user)) { + return K_FACTOR_NEW_PLAYER; + } else if (user.getLeaguePoints() < POINTS_THRESHOLD_LOW) { + return K_FACTOR_LOW_POINTS; + } else if (user.getLeaguePoints() < POINTS_THRESHOLD_HIGH) { + return K_FACTOR_MEDIUM_POINTS; + } else { + return K_FACTOR_HIGH_POINTS; + } + } + + /** + * Check if the user's earliest merged pull request is within the last 30 days. + * @param user + * @return true if the pull request is within the last 30 days + */ + private boolean isNewPlayer(User user) { + return user.getMergedPullRequests().stream() + .filter(PullRequest::isMerged) + .map(PullRequest::getMergedAt) + .noneMatch(date -> date.isAfter(OffsetDateTime.now().minusDays(30))); + } + + /** + * Calculate the base decay in points based on the current points. + * @param currentPoints Current amount of league points + * @return Amount of decay points + */ + private int calculateDecay(int currentPoints) { + // decay a part of the current points, at least DECAY_MINIMUM points + return currentPoints > 0 ? Math.max(DECAY_MINIMUM, (int)(currentPoints * DECAY_FACTOR)) : 0; + } + + /** + * Calculate the bonus points based on the leaderboard score. + * @param score Leaderboard score + * @return Bonus points + */ + private int calculatePerformanceBonus(int score) { + // Convert leaderboard score directly to points with diminishing returns + return (int)(Math.sqrt(score) * 10); + } + + /** + * Calculate the bonus points based on the placement in the leaderboard. + * @param placement Placement in the leaderboard + * @return Bonus points + */ + private int calculatePlacementBonus(int placement) { + // Bonus for top 3 placements + return placement <= 3 ? 20 * (4 - placement) : 0; + } +} diff --git a/server/application-server/src/main/java/de/tum/in/www1/hephaestus/leaderboard/tasks/LeaguePointsUpdateTask.java b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/leaderboard/tasks/LeaguePointsUpdateTask.java new file mode 100644 index 00000000..c9a4a48d --- /dev/null +++ b/server/application-server/src/main/java/de/tum/in/www1/hephaestus/leaderboard/tasks/LeaguePointsUpdateTask.java @@ -0,0 +1,70 @@ +package de.tum.in.www1.hephaestus.leaderboard.tasks; + +import de.tum.in.www1.hephaestus.gitprovider.user.UserRepository; +import de.tum.in.www1.hephaestus.leaderboard.LeaderboardEntryDTO; +import de.tum.in.www1.hephaestus.leaderboard.LeaderboardService; +import de.tum.in.www1.hephaestus.leaderboard.LeaguePointsCalculationService; +import jakarta.transaction.Transactional; +import java.time.DayOfWeek; +import java.time.OffsetDateTime; +import java.time.temporal.TemporalAdjusters; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class LeaguePointsUpdateTask implements Runnable { + + @Value("${hephaestus.leaderboard.schedule.day}") + private String scheduledDay; + + @Value("${hephaestus.leaderboard.schedule.time}") + private String scheduledTime; + + @Autowired + private UserRepository userRepository; + @Autowired + private LeaderboardService leaderboardService; + @Autowired + private LeaguePointsCalculationService leaguePointsCalculationService; + + @Override + @Transactional + public void run() { + List leaderboard = getLatestLeaderboard(); + leaderboard.forEach(updateLeaderboardEntry()); + } + + /** + * Update ranking points of a user based on its leaderboard entry. + * @return {@code Consumer} that updates {@code leaguePoints} based on its leaderboard entry. + */ + private Consumer updateLeaderboardEntry() { + return entry -> { + var user = userRepository.findByLoginWithEagerMergedPullRequests(entry.user().login()).orElseThrow(); + int newPoints = leaguePointsCalculationService.calculateNewPoints(user, entry); + user.setLeaguePoints(newPoints); + userRepository.save(user); + }; + } + + /** + * Retrieves the latest leaderboard based on the scheduled time of the environment. + * @return List of {@code LeaderboardEntryDTO} representing the latest leaderboard + */ + private List getLatestLeaderboard() { + String[] timeParts = scheduledTime.split(":"); + OffsetDateTime before = OffsetDateTime.now() + .with(TemporalAdjusters.previousOrSame(DayOfWeek.of(Integer.parseInt(scheduledDay)))) + .withHour(Integer.parseInt(timeParts[0])) + .withMinute(timeParts.length > 0 ? Integer.parseInt(timeParts[1]) : 0) + .withSecond(0) + .withNano(0); + OffsetDateTime after = before.minusWeeks(1); + return leaderboardService.createLeaderboard(after, before, Optional.empty()); + } + +} diff --git a/server/application-server/src/main/resources/db/changelog/1732966971482_changelog.xml b/server/application-server/src/main/resources/db/changelog/1732966971482_changelog.xml new file mode 100644 index 00000000..215a8d73 --- /dev/null +++ b/server/application-server/src/main/resources/db/changelog/1732966971482_changelog.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/server/application-server/src/main/resources/db/master.xml b/server/application-server/src/main/resources/db/master.xml index 23545da8..5e23a6ef 100644 --- a/server/application-server/src/main/resources/db/master.xml +++ b/server/application-server/src/main/resources/db/master.xml @@ -8,4 +8,5 @@ +