From 02eca5637f0ce444232634e328db763a891bdf10 Mon Sep 17 00:00:00 2001 From: RaidMax Date: Sun, 7 May 2023 20:33:58 -0500 Subject: [PATCH] update server distribution calculations to account for performance bucket --- .../IServerDistributionCalculator.cs | 6 +- .../Client/ServerDistributionCalculator.cs | 144 +++++++++++----- .../Config/PerformanceBucketConfiguration.cs | 10 ++ Plugins/Stats/Config/StatsConfiguration.cs | 2 + .../AdvancedClientStatsResourceQueryHelper.cs | 7 +- Plugins/Stats/Helpers/StatManager.cs | 161 +++++++++++------- .../Configuration/ServerConfiguration.cs | 1 + 7 files changed, 217 insertions(+), 114 deletions(-) create mode 100644 Plugins/Stats/Config/PerformanceBucketConfiguration.cs diff --git a/Plugins/Stats/Client/Abstractions/IServerDistributionCalculator.cs b/Plugins/Stats/Client/Abstractions/IServerDistributionCalculator.cs index 9630c1232..a3ebbdc71 100644 --- a/Plugins/Stats/Client/Abstractions/IServerDistributionCalculator.cs +++ b/Plugins/Stats/Client/Abstractions/IServerDistributionCalculator.cs @@ -5,7 +5,7 @@ namespace Stats.Client.Abstractions public interface IServerDistributionCalculator { Task Initialize(); - Task GetZScoreForServer(long serverId, double value); - Task GetRatingForZScore(double? value); + Task GetZScoreForServerOrBucket(double value, long? serverId = null, string performanceBucket = null); + Task GetRatingForZScore(double? value, string performanceBucket); } -} \ No newline at end of file +} diff --git a/Plugins/Stats/Client/ServerDistributionCalculator.cs b/Plugins/Stats/Client/ServerDistributionCalculator.cs index 76e31eae4..6ff307972 100644 --- a/Plugins/Stats/Client/ServerDistributionCalculator.cs +++ b/Plugins/Stats/Client/ServerDistributionCalculator.cs @@ -7,10 +7,9 @@ using Data.Abstractions; using Data.Models.Client; using Data.Models.Client.Stats; using IW4MAdmin.Plugins.Stats; -using IW4MAdmin.Plugins.Stats.Config; using Microsoft.EntityFrameworkCore; using SharedLibraryCore; -using SharedLibraryCore.Interfaces; +using SharedLibraryCore.Configuration; using Stats.Client.Abstractions; using Stats.Config; using Stats.Helpers; @@ -21,76 +20,119 @@ namespace Stats.Client { private readonly IDatabaseContextFactory _contextFactory; - private readonly IDataValueCache> + private readonly IDataValueCache> _distributionCache; - private readonly IDataValueCache - _maxZScoreCache; + private readonly IDataValueCache _maxZScoreCache; - private readonly IConfigurationHandler _configurationHandler; - private readonly List _serverIds = new List(); + private readonly StatsConfiguration _configuration; + private readonly ApplicationConfiguration _appConfig; + private readonly List _serverIds = new(); private const string DistributionCacheKey = nameof(DistributionCacheKey); private const string MaxZScoreCacheKey = nameof(MaxZScoreCacheKey); public ServerDistributionCalculator(IDatabaseContextFactory contextFactory, - IDataValueCache> distributionCache, + IDataValueCache> distributionCache, IDataValueCache maxZScoreCache, - IConfigurationHandlerFactory configFactory) + StatsConfiguration config, ApplicationConfiguration appConfig) { _contextFactory = contextFactory; _distributionCache = distributionCache; _maxZScoreCache = maxZScoreCache; - _configurationHandler = configFactory.GetConfigurationHandler("StatsPluginSettings"); + _configuration = config; + _appConfig = appConfig; } public async Task Initialize() { await LoadServers(); - _distributionCache.SetCacheItem((async (set, token) => + + _distributionCache.SetCacheItem(async (set, token) => { - await _configurationHandler.BuildAsync(); - var validPlayTime = _configurationHandler.Configuration()?.TopPlayersMinPlayTime ?? 3600 * 3; - - var distributions = new Dictionary(); + var validPlayTime = _configuration.TopPlayersMinPlayTime; + var distributions = new Dictionary(); await LoadServers(); + var iqPerformances = set + .Where(s => s.Skill > 0) + .Where(s => s.EloRating > 0) + .Where(s => s.Client.Level != EFClient.Permission.Banned); + foreach (var serverId in _serverIds) { - var performance = await set - .Where(s => s.ServerId == serverId) - .Where(s => s.Skill > 0) - .Where(s => s.EloRating > 0) - .Where(s => s.Client.Level != EFClient.Permission.Banned) + var performances = await iqPerformances.Where(s => s.ServerId == serverId) .Where(s => s.TimePlayed >= validPlayTime) .Where(s => s.UpdatedAt >= Extensions.FifteenDaysAgo()) - .Select(s => s.EloRating * 1 / 3.0 + s.Skill * 2 / 3.0).ToListAsync(); - var distributionParams = performance.GenerateDistributionParameters(); - distributions.Add(serverId, distributionParams); + .Select(s => s.EloRating * 1 / 3.0 + s.Skill * 2 / 3.0) + .ToListAsync(token); + var distributionParams = performances.GenerateDistributionParameters(); + distributions.Add(serverId.ToString(), distributionParams); + } + + foreach (var server in _appConfig.Servers) + { + if (string.IsNullOrWhiteSpace(server.PerformanceBucket)) + { + continue; + } + + var bucketConfig = + _configuration.PerformanceBuckets.FirstOrDefault(bucket => + bucket.Name == server.PerformanceBucket) ?? new PerformanceBucketConfiguration(); + + var oldestPerf = DateTimeOffset.UtcNow - bucketConfig.RankingExpiration; + var performances = await iqPerformances + .Where(perf => perf.Server.PerformanceBucket == server.PerformanceBucket) + .Where(perf => perf.TimePlayed >= bucketConfig.ClientMinPlayTime.TotalSeconds) + .Where(perf => perf.UpdatedAt >= oldestPerf) + .Select(s => s.EloRating * 1 / 3.0 + s.Skill * 2 / 3.0) + .ToListAsync(token); + var distributionParams = performances.GenerateDistributionParameters(); + distributions.Add(server.PerformanceBucket, distributionParams); } return distributions; - }), DistributionCacheKey, Utilities.IsDevelopment ? TimeSpan.FromMinutes(5) : TimeSpan.FromHours(1)); + }, DistributionCacheKey, Utilities.IsDevelopment ? TimeSpan.FromMinutes(5) : TimeSpan.FromHours(1)); - _maxZScoreCache.SetCacheItem(async (set, token) => + foreach (var server in _appConfig.Servers) { - await _configurationHandler.BuildAsync(); - var validPlayTime = _configurationHandler.Configuration()?.TopPlayersMinPlayTime ?? 3600 * 3; + _maxZScoreCache.SetCacheItem(async (set, ids, token) => + { + var validPlayTime = _configuration.TopPlayersMinPlayTime; + var oldestStat = TimeSpan.FromSeconds(_configuration.TopPlayersMinPlayTime); + var performanceBucket = (string)ids.FirstOrDefault(); - var zScore = await set - .Where(AdvancedClientStatsResourceQueryHelper.GetRankingFunc(validPlayTime)) - .Where(s => s.Skill > 0) - .Where(s => s.EloRating > 0) - .GroupBy(stat => stat.ClientId) - .Select(group => - group.Sum(stat => stat.ZScore * stat.TimePlayed) / group.Sum(stat => stat.TimePlayed)) - .MaxAsync(avgZScore => (double?) avgZScore, token); - return zScore ?? 0; - }, MaxZScoreCacheKey, Utilities.IsDevelopment ? TimeSpan.FromMinutes(5) : TimeSpan.FromMinutes(30)); + if (!string.IsNullOrEmpty(performanceBucket)) + { + var bucketConfig = + _configuration.PerformanceBuckets.FirstOrDefault(cfg => + cfg.Name == performanceBucket) ?? new PerformanceBucketConfiguration(); + + validPlayTime = (int)bucketConfig.ClientMinPlayTime.TotalSeconds; + oldestStat = bucketConfig.RankingExpiration; + } + + var zScore = await set + .Where(AdvancedClientStatsResourceQueryHelper.GetRankingFunc(validPlayTime, oldestStat)) + .Where(s => s.Skill > 0) + .Where(s => s.EloRating > 0) + .Where(stat => + performanceBucket == null || performanceBucket == stat.Server.PerformanceBucket) + .GroupBy(stat => stat.ClientId) + .Select(group => + group.Sum(stat => stat.ZScore * stat.TimePlayed) / group.Sum(stat => stat.TimePlayed)) + .MaxAsync(avgZScore => (double?)avgZScore, token); + + return zScore ?? 0; + }, MaxZScoreCacheKey, new[] { server.PerformanceBucket }, + Utilities.IsDevelopment ? TimeSpan.FromMinutes(5) : TimeSpan.FromMinutes(30)); + + await _maxZScoreCache.GetCacheItem(MaxZScoreCacheKey, new[] { server.PerformanceBucket }); + } await _distributionCache.GetCacheItem(DistributionCacheKey, new CancellationToken()); - await _maxZScoreCache.GetCacheItem(MaxZScoreCacheKey, new CancellationToken()); /*foreach (var serverId in _serverIds) { @@ -131,16 +173,28 @@ namespace Stats.Client } } - public async Task GetZScoreForServer(long serverId, double value) + public async Task GetZScoreForServerOrBucket(double value, long? serverId = null, + string performanceBucket = null) { - var serverParams = await _distributionCache.GetCacheItem(DistributionCacheKey, new CancellationToken()); - if (!serverParams.ContainsKey(serverId)) + if (serverId is null && performanceBucket is null) { return 0.0; } - var sdParams = serverParams[serverId]; - if (sdParams.Sigma == 0) + var serverParams = await _distributionCache.GetCacheItem(DistributionCacheKey, new CancellationToken()); + Extensions.LogParams sdParams = null; + + if (serverId is not null && serverParams.TryGetValue(serverId.ToString(), out var sdParams1)) + { + sdParams = sdParams1; + } + + else if (performanceBucket is not null && serverParams.TryGetValue(performanceBucket, out var sdParams2)) + { + sdParams = sdParams2; + } + + if (sdParams is null || sdParams.Sigma == 0) { return 0.0; } @@ -149,9 +203,9 @@ namespace Stats.Client return zScore; } - public async Task GetRatingForZScore(double? value) + public async Task GetRatingForZScore(double? value, string performanceBucket) { - var maxZScore = await _maxZScoreCache.GetCacheItem(MaxZScoreCacheKey, new CancellationToken()); + var maxZScore = await _maxZScoreCache.GetCacheItem(MaxZScoreCacheKey, new [] { performanceBucket }); return maxZScore == 0 ? null : value.GetRatingForZScore(maxZScore); } } diff --git a/Plugins/Stats/Config/PerformanceBucketConfiguration.cs b/Plugins/Stats/Config/PerformanceBucketConfiguration.cs new file mode 100644 index 000000000..cbfae6fb1 --- /dev/null +++ b/Plugins/Stats/Config/PerformanceBucketConfiguration.cs @@ -0,0 +1,10 @@ +using System; + +namespace Stats.Config; + +public class PerformanceBucketConfiguration +{ + public string Name { get; set; } + public TimeSpan ClientMinPlayTime { get; set; } = TimeSpan.FromHours(3); + public TimeSpan RankingExpiration { get; set; } = TimeSpan.FromDays(15); +} diff --git a/Plugins/Stats/Config/StatsConfiguration.cs b/Plugins/Stats/Config/StatsConfiguration.cs index 024b5008e..7bec5d1ba 100644 --- a/Plugins/Stats/Config/StatsConfiguration.cs +++ b/Plugins/Stats/Config/StatsConfiguration.cs @@ -19,6 +19,8 @@ namespace Stats.Config public int MostKillsClientLimit { get; set; } = 5; public bool EnableAdvancedMetrics { get; set; } = true; + public List PerformanceBuckets { get; set; } = new(); + public WeaponNameParserConfiguration[] WeaponNameParserConfigurations { get; set; } = { new() { diff --git a/Plugins/Stats/Helpers/AdvancedClientStatsResourceQueryHelper.cs b/Plugins/Stats/Helpers/AdvancedClientStatsResourceQueryHelper.cs index 994a3ad3e..31d3ce556 100644 --- a/Plugins/Stats/Helpers/AdvancedClientStatsResourceQueryHelper.cs +++ b/Plugins/Stats/Helpers/AdvancedClientStatsResourceQueryHelper.cs @@ -145,11 +145,12 @@ namespace Stats.Helpers }; } - public static Expression> GetRankingFunc(int minPlayTime, double? zScore = null, + public static Expression> GetRankingFunc(int minPlayTime, TimeSpan expiration, double? zScore = null, long? serverId = null) { - return (stats) => (serverId == null || stats.ServerId == serverId) && - stats.UpdatedAt >= Extensions.FifteenDaysAgo() && + var oldestStat = DateTimeOffset.UtcNow.Subtract(expiration); + return stats => (serverId == null || stats.ServerId == serverId) && + stats.UpdatedAt >= oldestStat && stats.Client.Level != EFClient.Permission.Banned && stats.TimePlayed >= minPlayTime && (zScore == null || stats.ZScore > zScore); diff --git a/Plugins/Stats/Helpers/StatManager.cs b/Plugins/Stats/Helpers/StatManager.cs index eec64dd71..3af42bd19 100644 --- a/Plugins/Stats/Helpers/StatManager.cs +++ b/Plugins/Stats/Helpers/StatManager.cs @@ -1,5 +1,4 @@ using IW4MAdmin.Plugins.Stats.Cheat; -using IW4MAdmin.Plugins.Stats.Config; using IW4MAdmin.Plugins.Stats.Web.Dtos; using Microsoft.EntityFrameworkCore; using SharedLibraryCore; @@ -1185,93 +1184,122 @@ namespace IW4MAdmin.Plugins.Stats.Helpers { await using var context = _contextFactory.CreateContext(); var minPlayTime = _config.TopPlayersMinPlayTime; + var oldestStat = DateTimeOffset.UtcNow - Extensions.FifteenDaysAgo(); + + var performanceBucket = + (await _serverCache.FirstAsync(server => server.Id == serverId)).PerformanceBucket; + + if (!string.IsNullOrEmpty(performanceBucket)) + { + var bucketConfig = _config.PerformanceBuckets.FirstOrDefault(cfg => cfg.Name == performanceBucket) ?? + new PerformanceBucketConfiguration(); + + minPlayTime = (int)bucketConfig.ClientMinPlayTime.TotalSeconds; + oldestStat = bucketConfig.RankingExpiration; + } var performances = await context.Set() .AsNoTracking() .Where(stat => stat.ClientId == clientId) .Where(stat => stat.ServerId != serverId) // ignore the one we're currently tracking - .Where(stats => stats.UpdatedAt >= Extensions.FifteenDaysAgo()) + .Where(stats => stats.UpdatedAt >= DateTimeOffset.UtcNow - oldestStat) .Where(stats => stats.TimePlayed >= minPlayTime) .ToListAsync(); if (clientStats.TimePlayed >= minPlayTime) { - clientStats.ZScore = await _serverDistributionCalculator.GetZScoreForServer(serverId, - clientStats.Performance); - - var serverRanking = await context.Set() - .Where(stats => stats.ClientId != clientStats.ClientId) - .Where(AdvancedClientStatsResourceQueryHelper.GetRankingFunc( - _config.TopPlayersMinPlayTime, clientStats.ZScore, serverId)) - .CountAsync(); - - var serverRankingSnapshot = new EFClientRankingHistory - { - ClientId = clientId, - ServerId = serverId, - ZScore = clientStats.ZScore, - Ranking = serverRanking, - PerformanceMetric = clientStats.Performance, - Newest = true - }; - - context.Add(serverRankingSnapshot); - await PruneOldRankings(context, clientId, serverId); - await context.SaveChangesAsync(); - + await UpdateForServer(clientId, clientStats, context, minPlayTime, oldestStat, serverId); performances.Add(clientStats); } if (performances.Any(performance => performance.TimePlayed >= minPlayTime)) { - var aggregateZScore = - performances.WeightValueByPlaytime(nameof(EFClientStatistics.ZScore), minPlayTime); - - int? aggregateRanking = await context.Set() - .Where(stat => stat.ClientId != clientId) - .Where(AdvancedClientStatsResourceQueryHelper.GetRankingFunc(minPlayTime)) - .GroupBy(stat => stat.ClientId) - .Where(group => - group.Sum(stat => stat.ZScore * stat.TimePlayed) / group.Sum(stat => stat.TimePlayed) > - aggregateZScore) - .Select(c => c.Key) - .CountAsync(); - - var newPerformanceMetric = await _serverDistributionCalculator.GetRatingForZScore(aggregateZScore); - - if (newPerformanceMetric == null) - { - _log.LogWarning("Could not determine performance metric for {Client} {AggregateZScore}", - clientStats.Client?.ToString(), aggregateZScore); - return; - } - - var aggregateRankingSnapshot = new EFClientRankingHistory - { - ClientId = clientId, - ZScore = aggregateZScore, - Ranking = aggregateRanking, - PerformanceMetric = newPerformanceMetric, - Newest = true, - }; - - context.Add(aggregateRankingSnapshot); - - await PruneOldRankings(context, clientId); - await context.SaveChangesAsync(); + await UpdateAggregateForServerOrBucket(clientId, clientStats, context, performances, minPlayTime, + oldestStat, performanceBucket); } } - private async Task PruneOldRankings(DatabaseContext context, int clientId, long? serverId = null) + private async Task UpdateAggregateForServerOrBucket(int clientId, EFClientStatistics clientStats, DatabaseContext context, List performances, + int minPlayTime, TimeSpan oldestStat, string performanceBucket) + { + var aggregateZScore = + performances.Where(performance => performance.Server.PerformanceBucket == performanceBucket) + .WeightValueByPlaytime(nameof(EFClientStatistics.ZScore), minPlayTime); + + int? aggregateRanking = await context.Set() + .Where(stat => stat.ClientId != clientId) + .Where(AdvancedClientStatsResourceQueryHelper.GetRankingFunc(minPlayTime, oldestStat)) + .GroupBy(stat => stat.ClientId) + .Where(group => + group.Sum(stat => stat.ZScore * stat.TimePlayed) / group.Sum(stat => stat.TimePlayed) > + aggregateZScore) + .Select(c => c.Key) + .CountAsync(); + + var newPerformanceMetric = await _serverDistributionCalculator.GetRatingForZScore(aggregateZScore, performanceBucket); + + if (newPerformanceMetric == null) + { + _log.LogWarning("Could not determine performance metric for {Client} {AggregateZScore}", + clientStats.Client?.ToString(), aggregateZScore); + return; + } + + var aggregateRankingSnapshot = new EFClientRankingHistory + { + ClientId = clientId, + ZScore = aggregateZScore, + Ranking = aggregateRanking, + PerformanceMetric = newPerformanceMetric, + PerformanceBucket = performanceBucket, + Newest = true, + }; + + context.Add(aggregateRankingSnapshot); + + await PruneOldRankings(context, clientId); + await context.SaveChangesAsync(); + } + + private async Task UpdateForServer(int clientId, EFClientStatistics clientStats, DatabaseContext context, + int minPlayTime, TimeSpan oldestStat, long? serverId = null) + { + clientStats.ZScore = + await _serverDistributionCalculator.GetZScoreForServerOrBucket(clientStats.Performance, serverId); + + var serverRanking = await context.Set() + .Where(stats => stats.ClientId != clientStats.ClientId) + .Where(AdvancedClientStatsResourceQueryHelper.GetRankingFunc(minPlayTime, oldestStat, + clientStats.ZScore, serverId)) + .CountAsync(); + + var serverRankingSnapshot = new EFClientRankingHistory + { + ClientId = clientId, + ServerId = serverId, + ZScore = clientStats.ZScore, + Ranking = serverRanking, + PerformanceMetric = clientStats.Performance, + Newest = true + }; + + context.Add(serverRankingSnapshot); + await PruneOldRankings(context, clientId, serverId); + await context.SaveChangesAsync(); + } + + private async Task PruneOldRankings(DatabaseContext context, int clientId, long? serverId = null, string performanceBucket = null) { var totalRankingEntries = await context.Set() .Where(r => r.ClientId == clientId) .Where(r => r.ServerId == serverId) + .Where(r => r.PerformanceBucket == performanceBucket) .CountAsync(); var mostRecent = await context.Set() .Where(r => r.ClientId == clientId) .Where(r => r.ServerId == serverId) + .Where(r => r.PerformanceBucket == performanceBucket) .FirstOrDefaultAsync(r => r.Newest); if (mostRecent != null) @@ -1287,6 +1315,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers var lastRating = await context.Set() .Where(r => r.ClientId == clientId) .Where(r => r.ServerId == serverId) + .Where(r => r.PerformanceBucket == performanceBucket) .OrderBy(r => r.CreatedDateTime) .FirstOrDefaultAsync(); @@ -1413,7 +1442,10 @@ namespace IW4MAdmin.Plugins.Stats.Helpers } clientStats.SPM = Math.Round(clientStats.SPM, 3); - clientStats.Skill = Math.Round((clientStats.SPM * KDRWeight), 3); + var skillFunction = client.GetAdditionalProperty>("SkillFunction"); + + clientStats.Skill = + skillFunction?.Invoke(client, clientStats) ?? Math.Round(clientStats.SPM * KDRWeight, 3); // fixme: how does this happen? if (double.IsNaN(clientStats.SPM) || double.IsNaN(clientStats.Skill)) @@ -1424,7 +1456,10 @@ namespace IW4MAdmin.Plugins.Stats.Helpers killSPM = killSpm, KDRWeight, totalPlayTime, SPMAgainstPlayWeight, clientStats, scoreDifference }); clientStats.SPM = 0; - clientStats.Skill = 0; + if (skillFunction is null) + { + clientStats.Skill = 0; + } } clientStats.LastStatCalculation = DateTime.UtcNow; diff --git a/SharedLibraryCore/Configuration/ServerConfiguration.cs b/SharedLibraryCore/Configuration/ServerConfiguration.cs index 0a6da74f5..877b4b9e2 100644 --- a/SharedLibraryCore/Configuration/ServerConfiguration.cs +++ b/SharedLibraryCore/Configuration/ServerConfiguration.cs @@ -54,6 +54,7 @@ namespace SharedLibraryCore.Configuration [LocalizedDisplayName("WEBFRONT_CONFIGURATION_SERVER_CUSTOM_HOSTNAME")] [ConfigurationOptional] public string CustomHostname { get; set; } + public string PerformanceBucket { get; set; } public IBaseConfiguration Generate() {