Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

League Points Calculation + Task Scheduling #195

Merged
merged 13 commits into from
Dec 4, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ public class User extends BaseGitServiceEntity {
@ToString.Exclude
private Set<PullRequestReviewComment> reviewComments = new HashSet<>();

// Current ranking points for the leaderboard leagues
private int leaguePoints;

public enum Type {
USER, ORGANIZATION, BOT
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ public interface UserRepository extends JpaRepository<User, Long> {
""")
Optional<User> findByLogin(@Param("login") String login);

@Query("""
SELECT u
FROM User u
LEFT JOIN FETCH u.mergedPullRequests
WHERE u.login = :login
""")
Optional<User> findByLoginWithEagerMergedPullRequests(@Param("login") String login);

@Query("""
SELECT u
FROM User u
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -41,6 +42,9 @@ public class LeaderboardTaskScheduler {
@Autowired
private SlackWeeklyLeaderboardTask slackWeeklyLeaderboardTask;

@Autowired
GODrums marked this conversation as resolved.
Show resolved Hide resolved
private LeaguePointsUpdateTask leaguePointsUpdateTask;


@EventListener(ApplicationReadyEvent.class)
public void activateTaskScheduler() {
Expand All @@ -61,7 +65,7 @@ public void activateTaskScheduler() {
}

scheduleSlackMessage(cron);

scheduleLeaguePointsUpdate(cron);
}

/**
Expand All @@ -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));
}
}
Original file line number Diff line number Diff line change
@@ -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 <a href="https://en.wikipedia.org/wiki/Elo_rating_system#Most_accurate_K-factor">Wikipedia: Most accurate K-factor</a>
*/
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;
}
}
Original file line number Diff line number Diff line change
@@ -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<LeaderboardEntryDTO> 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<? super LeaderboardEntryDTO> 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<LeaderboardEntryDTO> 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());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext" xmlns:pro="http://www.liquibase.org/xml/ns/pro" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd http://www.liquibase.org/xml/ns/pro http://www.liquibase.org/xml/ns/pro/liquibase-pro-latest.xsd http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<changeSet author="godrums" id="1732966971482-1">
<addColumn tableName="user">
<column name="league_points" type="integer" defaultValue="0">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>
1 change: 1 addition & 0 deletions server/application-server/src/main/resources/db/master.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,5 @@
<include file="./changelog/0000000000000_initial_schema.xml" relativeToChangelogFile="true"/>
<include file="./changelog/1731511193057_changelog.xml" relativeToChangelogFile="true"/>
<include file="./changelog/1732399349184_changelog.xml" relativeToChangelogFile="true"/>
<include file="./changelog/1732966971482_changelog.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
Loading