From 3d81ec45330fc5340f760234c146fbe8a45d3e73 Mon Sep 17 00:00:00 2001 From: Zelytra Date: Tue, 11 Jun 2024 14:35:33 +0200 Subject: [PATCH] PoolPoint system (#24) --- .../poolpoint/LeaderboardEndpoint.java | 33 ++++ .../poolpoint/PoolPointCalculator.java | 55 +++++++ .../poolpoint/UserLeaderBoardPosition.java | 4 + .../java/fr/zelytra/user/UserEndpoint.java | 1 + .../main/java/fr/zelytra/user/UserEntity.java | 23 ++- .../java/fr/zelytra/user/UserService.java | 25 +++- .../user/reflections/LeaderBoardUser.java | 7 + .../fr/zelytra/friend/FriendEndpointTest.java | 1 - .../poolpoint/LeaderboardEndpointTest.java | 95 ++++++++++++ .../poolpoint/PoolPointCalculatorTest.java | 91 +++++++++++ .../simulation/PoolPlayerSimulator.java | 141 ++++++++++++++++++ .../simulation/PoolSimulationUser.java | 28 ++++ webapp/src/components/LeaderBoard.vue | 38 +++-- webapp/src/objects/pool/Leaderboard.ts | 2 +- 14 files changed, 525 insertions(+), 19 deletions(-) create mode 100644 backend/src/main/java/fr/zelytra/poolpoint/LeaderboardEndpoint.java create mode 100644 backend/src/main/java/fr/zelytra/poolpoint/PoolPointCalculator.java create mode 100644 backend/src/main/java/fr/zelytra/poolpoint/UserLeaderBoardPosition.java create mode 100644 backend/src/main/java/fr/zelytra/user/reflections/LeaderBoardUser.java create mode 100644 backend/src/test/java/fr/zelytra/poolpoint/LeaderboardEndpointTest.java create mode 100644 backend/src/test/java/fr/zelytra/poolpoint/PoolPointCalculatorTest.java create mode 100644 backend/src/test/java/fr/zelytra/poolpoint/simulation/PoolPlayerSimulator.java create mode 100644 backend/src/test/java/fr/zelytra/poolpoint/simulation/PoolSimulationUser.java diff --git a/backend/src/main/java/fr/zelytra/poolpoint/LeaderboardEndpoint.java b/backend/src/main/java/fr/zelytra/poolpoint/LeaderboardEndpoint.java new file mode 100644 index 0000000..3aa83f2 --- /dev/null +++ b/backend/src/main/java/fr/zelytra/poolpoint/LeaderboardEndpoint.java @@ -0,0 +1,33 @@ +package fr.zelytra.poolpoint; + +import fr.zelytra.logger.LogEndpoint; +import fr.zelytra.user.UserService; +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; + +@Path("/leaderboard") +public class LeaderboardEndpoint { + + @Inject + UserService userService; + + @Inject + SecurityIdentity securityIdentity; + + @GET + @LogEndpoint + @Path("/all") + public Response getTopLeaderboard() { + return Response.ok(userService.getUsersOrderByPoolPoint()).build(); + } + + @GET + @LogEndpoint + @Path("/self") + public Response getUserLeaderboardPosition() { + return Response.ok(userService.getUsersLeaderboardPosition(securityIdentity.getPrincipal().getName())).build(); + } +} diff --git a/backend/src/main/java/fr/zelytra/poolpoint/PoolPointCalculator.java b/backend/src/main/java/fr/zelytra/poolpoint/PoolPointCalculator.java new file mode 100644 index 0000000..9d467c1 --- /dev/null +++ b/backend/src/main/java/fr/zelytra/poolpoint/PoolPointCalculator.java @@ -0,0 +1,55 @@ +package fr.zelytra.poolpoint; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +public class PoolPointCalculator { + + private final int poolpoint; + private final int k; // K-factor + private final int totalGamePlayed; + + public PoolPointCalculator(int poolpoint, int totalGamePlayed) { + this.poolpoint = poolpoint; + this.totalGamePlayed = totalGamePlayed; + this.k = getFactorK(); + } + + /** + * @param partyStatus Win = 1 | Loose = 0 | Draw = 0.5 + * @param opponentElo Poolpoint of opponent + * @return En+1 = En + K * (W - p(D)) The new elo of the player + */ + public int computeNewElo(double partyStatus, int opponentElo) { + return (int) Math.round(poolpoint + k * (partyStatus - getWinProbability(opponentElo))); + } + + public int getFactorK() { + // New player with less than 30 games and less than 2300 of score + if (totalGamePlayed < 30 && poolpoint < 2300) { + return 40; + } + // Old player with less than 2400 of score + else if (poolpoint < 2400) { + return 20; + } + // Old player with a superior or equal score of 2400 + return 10; + } + + /** + * The function p(D) Which represent win probability + * + * @param opponentElo Poolpoint of opponent + * @return p(D) Gain probability + */ + public double getWinProbability(int opponentElo) { + double eloDelta = poolpoint - opponentElo; // D + if (Math.abs(eloDelta) > 400 && eloDelta != 0) { + eloDelta = (400 * eloDelta) / Math.abs(eloDelta); + } + return BigDecimal.valueOf(1 / (1 + Math.pow(10, -eloDelta / 400))).setScale(3, RoundingMode.HALF_UP).doubleValue(); + } + + +} diff --git a/backend/src/main/java/fr/zelytra/poolpoint/UserLeaderBoardPosition.java b/backend/src/main/java/fr/zelytra/poolpoint/UserLeaderBoardPosition.java new file mode 100644 index 0000000..e69349e --- /dev/null +++ b/backend/src/main/java/fr/zelytra/poolpoint/UserLeaderBoardPosition.java @@ -0,0 +1,4 @@ +package fr.zelytra.poolpoint; + +public record UserLeaderBoardPosition(String username, int pp, long position) { +} diff --git a/backend/src/main/java/fr/zelytra/user/UserEndpoint.java b/backend/src/main/java/fr/zelytra/user/UserEndpoint.java index 772e31d..f3bac87 100644 --- a/backend/src/main/java/fr/zelytra/user/UserEndpoint.java +++ b/backend/src/main/java/fr/zelytra/user/UserEndpoint.java @@ -30,6 +30,7 @@ public class UserEndpoint { @GET @Path("/preferences") + @LogEndpoint @Transactional public Response getPreferences() { diff --git a/backend/src/main/java/fr/zelytra/user/UserEntity.java b/backend/src/main/java/fr/zelytra/user/UserEntity.java index 5388562..978d97a 100644 --- a/backend/src/main/java/fr/zelytra/user/UserEntity.java +++ b/backend/src/main/java/fr/zelytra/user/UserEntity.java @@ -29,17 +29,31 @@ public class UserEntity extends PanacheEntityBase { @Column(columnDefinition = "integer") private int pp; + @Column(columnDefinition = "integer") + private int gamePlayed; + public UserEntity() { } public UserEntity(String username){ Log.info("New user created : " + username); - this.pp = 100;//TODO Need to be modify when rating system is implemented + this.pp = 1200; + this.gamePlayed = 0; this.authUsername=username; this.username=username; this.online=true; this.persistAndFlush(); } + public UserEntity(UserEntity user) { + this.authUsername = user.authUsername; + this.username = user.username; + this.icon = user.icon; + this.online = user.online; + this.createdAt = user.createdAt; + this.pp = user.pp; + this.gamePlayed = user.gamePlayed; + } + @PrePersist protected void onCreate() { createdAt = LocalDateTime.now(); @@ -93,4 +107,11 @@ public void setPp(int pp) { this.pp = pp; } + public int getGamePlayed() { + return gamePlayed; + } + + public void setGamePlayed(int gamePlayed) { + this.gamePlayed = gamePlayed; + } } diff --git a/backend/src/main/java/fr/zelytra/user/UserService.java b/backend/src/main/java/fr/zelytra/user/UserService.java index b160328..a7ce489 100644 --- a/backend/src/main/java/fr/zelytra/user/UserService.java +++ b/backend/src/main/java/fr/zelytra/user/UserService.java @@ -1,9 +1,19 @@ package fr.zelytra.user; +import fr.zelytra.poolpoint.UserLeaderBoardPosition; +import fr.zelytra.user.reflections.LeaderBoardUser; +import io.quarkus.hibernate.orm.panache.PanacheRepository; import jakarta.enterprise.context.ApplicationScoped; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +import java.util.List; @ApplicationScoped -public class UserService { +public class UserService implements PanacheRepository { + + @PersistenceContext + EntityManager em; public UserEntity getOrCreateUserByName(String username) { UserEntity user = UserEntity.findById(username); @@ -19,4 +29,17 @@ public UserEntity getOrCreateUserByName(String username) { public UserEntity getUserByName(String username) { return UserEntity.findById(username); } + + public List getUsersOrderByPoolPoint() { + return UserEntity.find("ORDER BY pp").page(0, 100).project(LeaderBoardUser.class).list(); + } + + public UserLeaderBoardPosition getUsersLeaderboardPosition(String username) { + UserEntity userEntity = getUserByName(username); + // Count the number of users with higher pp + Long position = em.createQuery("SELECT COUNT(u) FROM UserEntity u WHERE u.pp > :userPp", Long.class) + .setParameter("userPp", userEntity.getPp()) + .getSingleResult(); + return new UserLeaderBoardPosition(userEntity.getAuthUsername(), userEntity.getPp(), position + 1); + } } diff --git a/backend/src/main/java/fr/zelytra/user/reflections/LeaderBoardUser.java b/backend/src/main/java/fr/zelytra/user/reflections/LeaderBoardUser.java new file mode 100644 index 0000000..c01608a --- /dev/null +++ b/backend/src/main/java/fr/zelytra/user/reflections/LeaderBoardUser.java @@ -0,0 +1,7 @@ +package fr.zelytra.user.reflections; + +import io.quarkus.runtime.annotations.RegisterForReflection; + +@RegisterForReflection +public record LeaderBoardUser(String username, String icon, int pp) { +} diff --git a/backend/src/test/java/fr/zelytra/friend/FriendEndpointTest.java b/backend/src/test/java/fr/zelytra/friend/FriendEndpointTest.java index 14c8bd3..1480642 100644 --- a/backend/src/test/java/fr/zelytra/friend/FriendEndpointTest.java +++ b/backend/src/test/java/fr/zelytra/friend/FriendEndpointTest.java @@ -26,7 +26,6 @@ class FriendEndpointTest { @Inject FriendService friendService; - @BeforeEach @Transactional void init() { diff --git a/backend/src/test/java/fr/zelytra/poolpoint/LeaderboardEndpointTest.java b/backend/src/test/java/fr/zelytra/poolpoint/LeaderboardEndpointTest.java new file mode 100644 index 0000000..0178de7 --- /dev/null +++ b/backend/src/test/java/fr/zelytra/poolpoint/LeaderboardEndpointTest.java @@ -0,0 +1,95 @@ +package fr.zelytra.poolpoint; + +import fr.zelytra.user.UserEntity; +import fr.zelytra.user.reflections.LeaderBoardUser; +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.oidc.server.OidcWiremockTestResource; +import io.restassured.common.mapper.TypeRef; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static io.quarkus.test.oidc.server.OidcWiremockTestResource.getAccessToken; +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@QuarkusTest +@QuarkusTestResource(OidcWiremockTestResource.class) +class LeaderboardEndpointTest { + + @BeforeAll + @Transactional + static void init() { + for (int x = 0; x < 200; x++) { + UserEntity user = new UserEntity("User" + x); + user.setPp(1000 + x); + } + } + + @Test + void getTopLeaderboard_maxResult100() { + List list = given() + .auth().oauth2(getAccessToken("User1", Set.of("user"))) + .when().get("/leaderboard/all") + .then().statusCode(200) + .extract() + .body() + .as(new TypeRef<>() { + }); + assertEquals(100, list.size(), "Should return max 100 results"); + } + + @Test + void getTopLeaderboard_orderByPP() { + List list = given() + .auth().oauth2(getAccessToken("User1", Set.of("user"))) + .when().get("/leaderboard/all") + .then().statusCode(200) + .extract() + .body() + .as(new TypeRef<>() { + }); + int index = 0; + for (LeaderBoardUser leaderBoardUser : list) { + assertEquals(1000 + index, leaderBoardUser.pp()); + index++; + } + } + + @Test + void getUserLeaderboardPosition_returnTheRightPosition1() { + UserLeaderBoardPosition userLeaderBoardPosition = given() + .auth().oauth2(getAccessToken("User0", Set.of("user"))) + .when().get("/leaderboard/self") + .then().statusCode(200) + .extract() + .body() + .as(UserLeaderBoardPosition.class); + assertEquals(200, userLeaderBoardPosition.position()); + assertEquals(1000,userLeaderBoardPosition.pp()); + } + + @Test + void getUserLeaderboardPosition_returnTheRightPosition2() { + UserLeaderBoardPosition userLeaderBoardPosition = given() + .auth().oauth2(getAccessToken("User42", Set.of("user"))) + .when().get("/leaderboard/self") + .then().statusCode(200) + .extract() + .body() + .as(UserLeaderBoardPosition.class); + assertEquals(158, userLeaderBoardPosition.position()); + assertEquals(1042,userLeaderBoardPosition.pp()); + } + + @AfterAll + @Transactional + static void cleanDataBase() { + UserEntity.deleteAll(); + } +} diff --git a/backend/src/test/java/fr/zelytra/poolpoint/PoolPointCalculatorTest.java b/backend/src/test/java/fr/zelytra/poolpoint/PoolPointCalculatorTest.java new file mode 100644 index 0000000..cafd69d --- /dev/null +++ b/backend/src/test/java/fr/zelytra/poolpoint/PoolPointCalculatorTest.java @@ -0,0 +1,91 @@ +package fr.zelytra.poolpoint; + +import fr.zelytra.poolpoint.simulation.PoolPlayerSimulator; +import io.quarkus.logging.Log; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.transaction.Transactional; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@QuarkusTest +class PoolPointCalculatorTest { + + @Test + @Transactional + void simulation() { + PoolPlayerSimulator poolPlayerSimulator = new PoolPlayerSimulator(101, 5000); + Log.info(poolPlayerSimulator); + poolPlayerSimulator.generateCSV("game_history.csv"); + } + + @Test + void getFactorK_lessThan30Games() { + int user1PP = 600; + int user1GamesPlayed = 25; + PoolPointCalculator poolPointCalculator = new PoolPointCalculator(user1PP, user1GamesPlayed); + assertEquals(40, poolPointCalculator.getFactorK()); + } + + @Test + void getFactorK_lessThan2400AndMoreThan30Games() { + int user1PP = 600; + int user1GamesPlayed = 80; + PoolPointCalculator poolPointCalculator = new PoolPointCalculator(user1PP, user1GamesPlayed); + assertEquals(20, poolPointCalculator.getFactorK()); + } + + @Test + void getFactorK_MoreThan2400() { + int user1PP = 2401; + int user1GamesPlayed = 80; + PoolPointCalculator poolPointCalculator = new PoolPointCalculator(user1PP, user1GamesPlayed); + assertEquals(10, poolPointCalculator.getFactorK()); + } + + @Test + void getWinProbability_deltaLessThan400() { + int user1PP = 1800; + int user1GamesPlayed = 80; + int user2PP = 1600; + PoolPointCalculator poolPointCalculator = new PoolPointCalculator(user1PP, user1GamesPlayed); + assertEquals(0.76, poolPointCalculator.getWinProbability(user2PP)); + assertEquals(20, poolPointCalculator.getFactorK()); + } + + @Test + void getWinProbability_deltaMoreThan400() { + int user1PP = 2600; + int user1GamesPlayed = 80; + int user2PP = 1600; + PoolPointCalculator poolPointCalculator = new PoolPointCalculator(user1PP, user1GamesPlayed); + assertEquals(0.909, poolPointCalculator.getWinProbability(user2PP)); + assertEquals(10, poolPointCalculator.getFactorK()); + } + + @Test + void computeNewElo_win() { + int user1PP = 2600; + int user1GamesPlayed = 80; + int user2PP = 1600; + PoolPointCalculator poolPointCalculator = new PoolPointCalculator(user1PP, user1GamesPlayed); + assertEquals(0.909, poolPointCalculator.getWinProbability(user2PP)); + assertEquals(10, poolPointCalculator.getFactorK()); + assertEquals(2601, poolPointCalculator.computeNewElo(1, user2PP)); + + } + + @Test + void computeNewElo_loose() { + int user1PP = 2600; + int user1GamesPlayed = 80; + int user2PP = 1600; + PoolPointCalculator poolPointCalculator = new PoolPointCalculator(user1PP, user1GamesPlayed); + assertEquals(0.909, poolPointCalculator.getWinProbability(user2PP)); + assertEquals(10, poolPointCalculator.getFactorK()); + assertEquals(2591, poolPointCalculator.computeNewElo(0, user2PP)); + + } + + +} diff --git a/backend/src/test/java/fr/zelytra/poolpoint/simulation/PoolPlayerSimulator.java b/backend/src/test/java/fr/zelytra/poolpoint/simulation/PoolPlayerSimulator.java new file mode 100644 index 0000000..6f5b85b --- /dev/null +++ b/backend/src/test/java/fr/zelytra/poolpoint/simulation/PoolPlayerSimulator.java @@ -0,0 +1,141 @@ +package fr.zelytra.poolpoint.simulation; + +import fr.zelytra.poolpoint.PoolPointCalculator; +import fr.zelytra.user.UserEntity; +import io.quarkus.logging.Log; + +import java.io.FileWriter; +import java.io.IOException; +import java.util.*; + +public class PoolPlayerSimulator { + + private final int playerAmount; + private final int iteration; + private final List playerList; + private final Map> gameHistoryMap; + + public PoolPlayerSimulator(int playerAmount, int iteration) { + this.playerAmount = playerAmount; + this.iteration = iteration; + this.playerList = new ArrayList<>(); + this.gameHistoryMap = new HashMap<>(); + generatePlayers(); + runSimulation(); + } + + private void runSimulation() { + gameHistoryMap.put(0, deepCopy(playerList)); + for (int i = 0; i < iteration; i++) { + Collections.shuffle(playerList); + for (int j = 0; j < playerAmount; j += 2) { + if (j + 1 < playerAmount) { + PoolSimulationUser player1 = playerList.get(j); + PoolSimulationUser player2 = playerList.get(j + 1); + playMatch(player1, player2); + } + } + gameHistoryMap.put(i + 1, deepCopy(playerList)); + } + } + + private List deepCopy(List playerList) { + List currentIterationPlayers = new ArrayList<>(); + for (PoolSimulationUser player : playerList) { + currentIterationPlayers.add(new PoolSimulationUser(player)); // Create a deep copy of the player + } + return currentIterationPlayers; + } + + private void playMatch(PoolSimulationUser player1, PoolSimulationUser player2) { + boolean player1Wins = isPlayerWinningTheGame(player1.getWeight(), player2.getWeight()); + PoolPointCalculator pointCalculatorPlayer1 = new PoolPointCalculator(player1.getPp(), player1.getGamePlayed()); + PoolPointCalculator pointCalculatorPlayer2 = new PoolPointCalculator(player2.getPp(), player2.getGamePlayed()); + + // Increment game amount + player1.setGamePlayed(player1.getGamePlayed() + 1); + player2.setGamePlayed(player2.getGamePlayed() + 1); + + // Update PP + player1.setPp(pointCalculatorPlayer1.computeNewElo(player1Wins ? 1 : 0, player2.getPp())); + player2.setPp(pointCalculatorPlayer2.computeNewElo(player1Wins ? 0 : 1, player1.getPp())); + } + + private void generatePlayers() { + for (int i = 0; i < playerAmount; i++) { + PoolSimulationUser user = new PoolSimulationUser("User" + i, i == playerAmount-1 ? 20 : (int) (Math.floor(i / 10) + 1)); + playerList.add(user); + } + } + + private boolean isPlayerWinningTheGame(int player1Weight, int player2Weight) { + Log.info(player1Weight + " " + player2Weight); + return Math.random() < ((double) player1Weight / Math.max(1, player1Weight + player2Weight)); + } + + public void generateCSV(String fileName) { + try (FileWriter writer = new FileWriter(fileName)) { + // Get all usernames and sort them + List usernames = playerList.stream() + .map(PoolSimulationUser::getUsername) + .sorted(new Comparator() { + + @Override + public int compare(String o1, String o2) { + if (o1.length() == o2.length()) { + return o1.compareTo(o2); + } + return o1.length() - o2.length(); + } + }) + .toList(); + + // Write header with sorted usernames + for (int i = 0; i < usernames.size(); i++) { + writer.append(usernames.get(i)); + if (i < usernames.size() - 1) { + writer.append(","); + } + } + writer.append("\n"); + + // Write scores for each iteration + for (Map.Entry> entry : gameHistoryMap.entrySet()) { + List players = entry.getValue(); + for (int i = 0; i < usernames.size(); i++) { + String username = usernames.get(i); + // Find the corresponding player in the current iteration + Optional optionalUser = players.stream() + .filter(p -> p.getUsername().equals(username)) + .findFirst(); + // Write the score if found, otherwise write 0 + if (optionalUser.isPresent()) { + writer.append(String.valueOf(optionalUser.get().getPp())); + } else { + writer.append("0"); + } + if (i < usernames.size() - 1) { + writer.append(","); + } + } + writer.append("\n"); + } + } catch (IOException e) { + e.printStackTrace(); + } + } + + @Override + public String toString() { + playerList.sort(Comparator.comparingInt(UserEntity::getPp).reversed()); + + StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.append("Rankings:\n"); + for (int i = 0; i < playerList.size(); i++) { + UserEntity player = playerList.get(i); + stringBuilder.append(String.format("%d. %s - Points: %d, Games Played: %d\n", + i + 1, player.getUsername(), player.getPp(), player.getGamePlayed())); + } + return stringBuilder.toString(); + } +} diff --git a/backend/src/test/java/fr/zelytra/poolpoint/simulation/PoolSimulationUser.java b/backend/src/test/java/fr/zelytra/poolpoint/simulation/PoolSimulationUser.java new file mode 100644 index 0000000..095c48d --- /dev/null +++ b/backend/src/test/java/fr/zelytra/poolpoint/simulation/PoolSimulationUser.java @@ -0,0 +1,28 @@ +package fr.zelytra.poolpoint.simulation; + +import fr.zelytra.user.UserEntity; +import jakarta.persistence.Entity; + +@Entity +public class PoolSimulationUser extends UserEntity { + + private int weight; + + public PoolSimulationUser(String username,int weight) { + super(username); + this.weight = weight; + } + + public PoolSimulationUser(PoolSimulationUser user) { + super(user); + weight = user.getWeight(); + } + + public PoolSimulationUser() { + + } + + public int getWeight() { + return weight; + } +} diff --git a/webapp/src/components/LeaderBoard.vue b/webapp/src/components/LeaderBoard.vue index fdf9597..2763765 100644 --- a/webapp/src/components/LeaderBoard.vue +++ b/webapp/src/components/LeaderBoard.vue @@ -1,9 +1,11 @@