diff --git a/GenOnlineService/Database/Database.MatchHistory.cs b/GenOnlineService/Database/Database.MatchHistory.cs index aead105..4a51236 100644 --- a/GenOnlineService/Database/Database.MatchHistory.cs +++ b/GenOnlineService/Database/Database.MatchHistory.cs @@ -1135,16 +1135,7 @@ private static async Task UpdateCurrentEloAsync( continue; Console.WriteLine($"[ELO] Pairing a={a.user_id}(won={a.won}) vs b={b.user_id}(won={b.won}) → result={(a.won ? "PlayerAWins" : "PlayerBWins")}"); - ref EloData A = ref CollectionsMarshal.GetValueRefOrAddDefault( - dictElo, a.user_id, out _); - - ref EloData B = ref CollectionsMarshal.GetValueRefOrAddDefault( - dictElo, b.user_id, out _); - - Elo.ApplyResult( - ref A, - ref B, - a.won ? MatchResult.PlayerAWins : MatchResult.PlayerBWins); + UpdateElo(dictElo, a.user_id, b.user_id, a.won); } } @@ -1214,26 +1205,9 @@ private static async Task UpdatePeriodEloAndLeaderboardsAsync( if (a.user_id >= b.user_id) continue; - // Daily - { - ref EloData A = ref CollectionsMarshal.GetValueRefOrAddDefault(daily, a.user_id, out _); - ref EloData B = ref CollectionsMarshal.GetValueRefOrAddDefault(daily, b.user_id, out _); - Elo.ApplyResult(ref A, ref B, a.won ? MatchResult.PlayerAWins : MatchResult.PlayerBWins); - } - - // Monthly - { - ref EloData A = ref CollectionsMarshal.GetValueRefOrAddDefault(monthly, a.user_id, out _); - ref EloData B = ref CollectionsMarshal.GetValueRefOrAddDefault(monthly, b.user_id, out _); - Elo.ApplyResult(ref A, ref B, a.won ? MatchResult.PlayerAWins : MatchResult.PlayerBWins); - } - - // Yearly - { - ref EloData A = ref CollectionsMarshal.GetValueRefOrAddDefault(yearly, a.user_id, out _); - ref EloData B = ref CollectionsMarshal.GetValueRefOrAddDefault(yearly, b.user_id, out _); - Elo.ApplyResult(ref A, ref B, a.won ? MatchResult.PlayerAWins : MatchResult.PlayerBWins); - } + UpdateElo(daily, a.user_id, b.user_id, a.won); + UpdateElo(monthly, a.user_id, b.user_id, a.won); + UpdateElo(yearly, a.user_id, b.user_id, a.won); } } @@ -1280,9 +1254,28 @@ await db.LeaderboardYearly } } + private static void UpdateElo(Dictionary dict, long firstPlayerId, long secondPlayerId, bool firstPlayerWon) + { + ref EloData? firstPlayer = ref CollectionsMarshal.GetValueRefOrAddDefault(dict, firstPlayerId, out bool existsA); + if (!existsA) + { + firstPlayer = new EloData(); + } + ref EloData? secondPlayer = ref CollectionsMarshal.GetValueRefOrAddDefault(dict, secondPlayerId, out bool existsB); + if (!existsB) + { + secondPlayer = new EloData(); + } - - + if (firstPlayerWon) + { + Elo.ApplyResult(firstPlayer, secondPlayer); + } + else + { + Elo.ApplyResult(secondPlayer, firstPlayer); + } + } } } diff --git a/GenOnlineService/ELO.cs b/GenOnlineService/ELO.cs index 73bba83..6fd6f87 100644 --- a/GenOnlineService/ELO.cs +++ b/GenOnlineService/ELO.cs @@ -15,72 +15,134 @@ ** You should have received a copy of the GNU Affero General Public License ** along with this program. If not, see . */ -using GenOnlineService; - -public enum MatchResult { PlayerAWins, PlayerBWins } +/// +/// Configuration settings for the Elo rating system. +/// public static class EloConfig { - public static int BaseRating { get; } = 1000; - public static int KFactor { get; } = 24; // base for per game volatility, increases for first 10 matches, lower after that + /// + /// The base rating for new players in the Elo system. + /// + public const int BaseRating = 1000; + + /// + /// The K-factor used in the Elo rating calculation, + /// which determines the volatility of rating changes. + /// + /// + /// The KFactor is the maximum number of points a player can + /// gain or lose in a single match. The KFactor may be modified, e.g. + /// for new players or players with fewer matches, to allow for faster rating adjustments. + /// + public const int KFactor = 24; + + /// + /// The Elo expansion value for standard players, used to adjust ratings over time. + /// + public const int EloExpansionValue_Standard = 50; + + /// + /// The Elo expansion value for high ELO players, used to adjust ratings over time. + /// + public const int EloExpansionValue_HighELO = 150; - public static int EloExpansionValue_Standard = 50; - public static int EloExpansionValue_HighELO = 150; - public static int SecondsBetweenEloExpansionsInMatchmaking = 10; + /// + /// The number of seconds between Elo expansions in matchmaking, which controls how frequently ratings are adjusted. + /// + public const int SecondsBetweenEloExpansionsInMatchmaking = 10; - public static int HighEloThreshold = 2000; + /// + /// The threshold rating that defines a high ELO player. Players with ratings above this value are considered high ELO players. + /// + public const int HighEloThreshold = 2000; } -public class EloData +/// +/// Represents a player's Elo rating data. +/// +/// The player's current Elo rating. +/// The player's Elo rating for the current month. +/// The number of matches the player has played. +public sealed class EloData(int rating, int monthlyRating, int matchCount) { - public int Rating { get; set; } = 1000; - public int NumMatches { get; set; } = 0; - public int MonthlyRating { get; set; } = 1000; + /// + /// Gets or sets the player's current Elo rating. + /// + public int Rating { get; set; } = rating; - public EloData(int rating, int numMatches) + /// + /// Gets or sets the number of matches the player has played. + /// + public int NumMatches { get; set; } = matchCount; + + /// + /// Gets or sets the player's Elo rating for the current month. + /// + public int MonthlyRating { get; set; } = monthlyRating; + + /// + /// Initializes a new instance of the class with default values. + /// + public EloData() + : this(EloConfig.BaseRating, EloConfig.BaseRating, 0) { - Rating = rating; - NumMatches = numMatches; } - public EloData(int rating, int monthlyRating, int numMatches) + /// + /// Initializes a new instance of the class with the specified rating and number of matches. + /// + /// The player's current Elo rating. + /// The number of matches the player has played. + public EloData(int rating, int numMatches) + : this(rating, EloConfig.BaseRating, numMatches) { - Rating = rating; - MonthlyRating = monthlyRating; - NumMatches = numMatches; } } +/// +/// Provides methods for calculating and updating Elo ratings based on match results. +/// public static class Elo { - public static double ExpectedScore(int ra, int rb) + /// + /// Applies the result of a match between two players, updating their Elo ratings accordingly. + /// + /// The player who won the match. + /// The player who lost the match. + public static void ApplyResult(EloData winner, EloData loser) { - // E_A = 1 / (1 + 10^((R_B - R_A)/400)) - return 1.0 / (1.0 + Math.Pow(10.0, (rb - ra) / 400.0)); + var winnerScore = GetExpectedScore(winner.Rating, loser.Rating); + var loserScore = 1.0 - winnerScore; + + var winnerKFactor = GetEffectiveKFactor(EloConfig.KFactor, winner.NumMatches); + var loserKFactor = GetEffectiveKFactor(EloConfig.KFactor, loser.NumMatches); + + winner.Rating += (int)Math.Round(winnerKFactor * (1.0 - winnerScore)); + loser.Rating -= (int)Math.Round(loserKFactor * loserScore); } - public static void ApplyResult(ref EloData playerDataA, ref EloData playerDataB, MatchResult result) + private static double GetExpectedScore(int player, int opponent) { - double ea = ExpectedScore(playerDataA.Rating, playerDataB.Rating); - double eb = 1.0 - ea; + return 1.0 / (1.0 + Math.Pow(10.0, (opponent - player) / 400.0)); + } - double sa = result switch + private static int GetEffectiveKFactor(int baseK, int numberOfGames) + { + // Brand new players get a higher K factor to + // allow their rating to adjust more quickly + if (numberOfGames < 10) { - MatchResult.PlayerAWins => 1.0, - MatchResult.PlayerBWins => 0.0, - _ => 0.5 - }; - double sb = 1.0 - sa; + return baseK * 2; + } - int kA = DynamicK(EloConfig.KFactor, playerDataA.NumMatches); - int kB = DynamicK(EloConfig.KFactor, playerDataB.NumMatches); - - playerDataA.Rating = playerDataA.Rating + (int)Math.Round(kA * (sa - ea)); + // Players with less than 100 games may still improve their game skill + // and therefore get a slightly higher K factor + if (numberOfGames< 100) + { + return (int) (baseK* 1.25); + } - playerDataB.Rating = playerDataB.Rating + (int)Math.Round(kB * (sb - eb)); + return baseK; } - - // note: higher K for new players; dampen after 100 games - private static int DynamicK(int baseK, int games) - => games < 10 ? baseK * 2 : (games < 100 ? (int)(baseK * 1.25) : baseK); }