Skip to content

Commit

Permalink
Feature: Rating (Elo) (#34)
Browse files Browse the repository at this point in the history
(Commit message generated with AI)

feat: Added EloService and updated UserEntity and ParticipantEntity

Introduced EloService for calculating user ratings.
Updated UserEntity: added eloPoints field for storing ratings in the database.
Updated ParticipantEntity: added temporary fields to track rating changes during tournaments.
Frontend changes to display user ratings.
feat: EloService minor updates

Modified EloService to initialize repository fields via constructor.
Updated UserDto: added eloPoints field.
Updated ParticipantEntity: temporary fields are now stored in the database.
Frontend changes for user rating display.
feat: EloService minor updates

Adjusted EloService logic for calculating tournament ratings, with a default rating of 1200 for new users.
Updated MatchEntity: added hasEloCalculated field.
Updated ParticipantEntity: made temporaryEloPoints transient.
Removed dependency on EloServiceImpl from RoundService.
In UserPane.tsx, users without ratings are labeled as "unrated."
feat: Major updates to EloServiceImpl

Frontend:

Improved rating logo and description for better user experience.
Backend:

Extended EloService functionality:

Added reverse rating calculation method to the EloService interface.
Introduced EloCalculationService interface for Elo logic.
Created EloUpdateResultDTO for passing calculation results.
Refactored services:

Moved rating initialization to UserEloInitializerService.
Moved Elo calculation to DefaultEloCalculationService.
Main rating logic centralized in EloServiceImpl.
Changes in EloServiceImpl:

Logic now depends on whether tournament Elo has been calculated.
Replaced match result storage with Map<UUID, Integer> currentEloMap, removed temporaryElo from ParticipantEntity.
Refactored processing:

Match processing moved to finishRound method.
Elo rollback handled in startTournament method.
Added unit tests:

Tests for match scenarios between authorized and unauthorized players.
Tests for Elo rollback and match result changes within tournaments.
REST API Updates:

Removed unused ratings endpoint.
Set default Elo to 0.
Replaced explicit constructor with annotation.
Moved isAuthorizedUser to SecurityUtil.
Various refactoring, renaming, and cleanup.
Added feature-flag for EloServiceImpl on both backend and frontend.
Added finalEloPoints field to ParticipantEntity and database.
Introduced AdvancedEloCalculationStrategy for standard Elo calculations.
Refactoring and Enhancements:

Refactored EloServiceImpl logic into smaller methods.
Updated processTournamentAndUpdateElo for improved Elo initialization and match handling.
Modified calculateAndApplyElo to properly apply changes and accumulate finalEloPoints.
Refactored finalizeTournament to apply final Elo changes and persist updated ratings.
Fixed issue in rollbackEloChanges to reset finalEloPoints correctly, preventing duplicate processing.
  • Loading branch information
UmarShabazov committed Sep 11, 2024
1 parent c4995f2 commit 1e0493f
Show file tree
Hide file tree
Showing 26 changed files with 1,201 additions and 8 deletions.
1 change: 1 addition & 0 deletions frontend/.env
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
REACT_APP_auth.signupWithPasswordEnabled=false
REACT_APP_eloServiceEnabled=false
1 change: 1 addition & 0 deletions frontend/src/lib/api/dto/MainPageData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface UserDto {
roles?: string[]
reputation?: number
globalScore?: number
eloPoints?: number;
}

export interface ListDto<T extends any> {
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/pages/MainPage/UserPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import Gravatar, {GravatarType} from "components/Gravatar";
import {Link} from "react-router-dom";
import {AiOutlineTrophy} from "react-icons/ai";
import {FaRegHeart} from "react-icons/fa";
import { FaChartLine } from "react-icons/fa6";
import React from "react";


export function UserPane(
{
user
Expand All @@ -16,6 +18,8 @@ export function UserPane(
let loc = useLoc()
let transliterate = useTransliterate()

const eloServiceEnabled = process.env.REACT_APP_eloServiceEnabled === 'true';

return <div key={user.id} className={"col-span-12 flex"}>
<div className={"h-[3em] w-[3em] inline-block overflow-hidden mr-2"}>
<Gravatar
Expand Down Expand Up @@ -56,6 +60,13 @@ export function UserPane(
<FaRegHeart className={"inline -mt-[1px] leading-4 align-bottom"}/>
<span className={""}>{user.reputation || 0}</span>
</div>

{eloServiceEnabled && (
<div className={"h-full leading-4 flex block align-bottom gap-1"} title={loc("Elo Points")}>
<FaChartLine className={"inline -mt-[1px] leading-4 align-bottom"}/>
<span>{user.eloPoints || "Unrated"}</span>
</div>
)}
</div>
</div>
</div>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

import java.time.LocalDate;
import java.util.*;
import java.util.stream.Collectors;

@RestController
@RequestMapping("/user")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.chessgrinder.chessgrinder.dto;

import lombok.Builder;
import lombok.Data;

@Data
@Builder
public class EloUpdateResultDto {
private final int whiteNewElo;
private final int blackNewElo;
}
2 changes: 2 additions & 0 deletions src/main/java/com/chessgrinder/chessgrinder/dto/UserDto.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ public class UserDto {
*/
private List<BadgeDto> badges;

private int eloPoints;

private List<String> roles;

private int reputation;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ public class ParticipantEntity extends AbstractAuditingEntity {
@Column(name = "nickname")
private String nickname;

@Column(name = "initial_elo_points")
private int initialEloPoints;

@Column (name ="final_elo_points")
private int finalEloPoints;

@Nonnull
@Column(name = "score")
private BigDecimal score;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ public class TournamentEntity extends AbstractAuditingEntity {
@Enumerated(EnumType. STRING)
private TournamentStatus status;

@Nullable
@Column(name = "has_elo_calculated")
private boolean hasEloCalculated;

/**
* Number of allowed rounds (not actual number of rounds)
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ public class UserEntity extends AbstractAuditingEntity {
@Column(name = "reputation")
private int reputation = 0;

@Column(name = "elo_points")
private int eloPoints = 0;

@Builder.Default
@Transient //won't be created in DB
private BigDecimal globalScore = BigDecimal.valueOf(-1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public UserDto toDto(
.name(user.getName())
.roles(user.getRoles().stream().map(RoleEntity::getName).collect(Collectors.toList()))
.reputation(user.getReputation())
.eloPoints(user.getEloPoints())
.globalScore(user.getGlobalScore())
.build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,7 @@ public static UserEntity tryGetUserEntity(@Nullable Authentication authenticatio
private SecurityUtil() {
}

public static boolean isAuthorizedUser(UserEntity user) {
return user != null && user.getId() != null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.chessgrinder.chessgrinder.service;

import com.chessgrinder.chessgrinder.dto.EloUpdateResultDto;
import com.chessgrinder.chessgrinder.enums.MatchResult;
import org.springframework.stereotype.Component;

@Component
public class AdvancedEloCalculationStrategy implements EloCalculationStrategy {

private static final double K_FACTOR = 32;
private static final int UNRATED_WIN_POINTS = 5;
private static final int UNRATED_LOSE_POINTS = -5;
private static final int UNRATED_DRAW_POINTS = 1;

public static double calculateExpectedScore(int whiteElo, int blackElo) {
return 1.0 / (1 + Math.pow(10, (blackElo - whiteElo) / 400.0));
}

public static int calculateNewElo(int whiteElo, int blackElo, double score) {
double expectedScore = calculateExpectedScore(whiteElo, blackElo);
return (int) Math.round(whiteElo + K_FACTOR * (score - expectedScore));
}


@Override
public EloUpdateResultDto calculateElo(int whiteElo, int blackElo, MatchResult result, boolean bothUsersAuthorized) {

if (result == null) {
return EloUpdateResultDto.builder()
.whiteNewElo(whiteElo)
.blackNewElo(blackElo)
.build();
}

int whitePoints = 0;
int blackPoints = 0;

if (result == MatchResult.WHITE_WIN) {
whitePoints = bothUsersAuthorized ? (calculateNewElo(whiteElo, blackElo, 1.0) - whiteElo) : UNRATED_WIN_POINTS;
blackPoints = bothUsersAuthorized ? (calculateNewElo(blackElo, whiteElo, 0) - blackElo) : UNRATED_LOSE_POINTS;
} else if (result == MatchResult.BLACK_WIN) {
whitePoints = bothUsersAuthorized ? (calculateNewElo(whiteElo, blackElo, 0) - whiteElo) : UNRATED_LOSE_POINTS;
blackPoints = bothUsersAuthorized ? (calculateNewElo(blackElo, whiteElo, 1.0) - blackElo) : UNRATED_WIN_POINTS;
} else if (result == MatchResult.DRAW) {
whitePoints = bothUsersAuthorized ? (calculateNewElo(whiteElo, blackElo, 0.5) - whiteElo) : UNRATED_DRAW_POINTS;
blackPoints = bothUsersAuthorized ? (calculateNewElo(blackElo, whiteElo, 0.5) - blackElo) : UNRATED_DRAW_POINTS;
}

int whiteNewElo = whiteElo + whitePoints;
int blackNewElo = blackElo + blackPoints;

return EloUpdateResultDto.builder()
.whiteNewElo(whiteNewElo)
.blackNewElo(blackNewElo)
.build();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.chessgrinder.chessgrinder.service;

import com.chessgrinder.chessgrinder.dto.EloUpdateResultDto;
import com.chessgrinder.chessgrinder.enums.MatchResult;
import org.springframework.stereotype.Component;


public class DefaultEloCalculationStrategy implements EloCalculationStrategy {

private static final int WIN_POINTS = 10;
private static final int LOSE_POINTS = -10;
private static final int UNRATED_WIN_POINTS = 5;
private static final int UNRATED_LOSE_POINTS = -5;

@Override
public EloUpdateResultDto calculateElo(int whiteElo, int blackElo, MatchResult result, boolean bothUsersAuthorized) {

if (result == null) {
return EloUpdateResultDto.builder()
.whiteNewElo(whiteElo)
.blackNewElo(blackElo)
.build();
}
int whitePoints = 0;
int blackPoints = 0;

int winPoints = bothUsersAuthorized ? WIN_POINTS : UNRATED_WIN_POINTS;
int losePoints = bothUsersAuthorized ? LOSE_POINTS : UNRATED_LOSE_POINTS;

if (result == MatchResult.WHITE_WIN) {
whitePoints = winPoints;
blackPoints = losePoints;
} else if (result == MatchResult.BLACK_WIN) {
whitePoints = losePoints;
blackPoints = winPoints;
}

int whiteNewElo = whiteElo + whitePoints;
int blackNewElo = blackElo + blackPoints;

return EloUpdateResultDto.builder()
.whiteNewElo(whiteNewElo)
.blackNewElo(blackNewElo)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.chessgrinder.chessgrinder.service;

import com.chessgrinder.chessgrinder.dto.EloUpdateResultDto;
import com.chessgrinder.chessgrinder.enums.MatchResult;

public interface EloCalculationStrategy {
EloUpdateResultDto calculateElo(int whiteElo, int blackElo, MatchResult result, boolean bothUsersAuthorized);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.chessgrinder.chessgrinder.service;

import com.chessgrinder.chessgrinder.entities.TournamentEntity;


public interface EloService {

void processTournamentAndUpdateElo(TournamentEntity tournament);
void rollbackEloChanges(TournamentEntity tournament);
}
Loading

0 comments on commit 1e0493f

Please sign in to comment.