update server distribution calculations to account for performance bucket

This commit is contained in:
RaidMax 2023-05-07 20:33:58 -05:00
parent 7d67a3dfc9
commit 02eca5637f
7 changed files with 217 additions and 114 deletions

View File

@ -5,7 +5,7 @@ namespace Stats.Client.Abstractions
public interface IServerDistributionCalculator public interface IServerDistributionCalculator
{ {
Task Initialize(); Task Initialize();
Task<double> GetZScoreForServer(long serverId, double value); Task<double> GetZScoreForServerOrBucket(double value, long? serverId = null, string performanceBucket = null);
Task<double?> GetRatingForZScore(double? value); Task<double?> GetRatingForZScore(double? value, string performanceBucket);
} }
} }

View File

@ -7,10 +7,9 @@ using Data.Abstractions;
using Data.Models.Client; using Data.Models.Client;
using Data.Models.Client.Stats; using Data.Models.Client.Stats;
using IW4MAdmin.Plugins.Stats; using IW4MAdmin.Plugins.Stats;
using IW4MAdmin.Plugins.Stats.Config;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SharedLibraryCore; using SharedLibraryCore;
using SharedLibraryCore.Interfaces; using SharedLibraryCore.Configuration;
using Stats.Client.Abstractions; using Stats.Client.Abstractions;
using Stats.Config; using Stats.Config;
using Stats.Helpers; using Stats.Helpers;
@ -21,76 +20,119 @@ namespace Stats.Client
{ {
private readonly IDatabaseContextFactory _contextFactory; private readonly IDatabaseContextFactory _contextFactory;
private readonly IDataValueCache<EFClientStatistics, Dictionary<long, Extensions.LogParams>> private readonly IDataValueCache<EFClientStatistics, Dictionary<string, Extensions.LogParams>>
_distributionCache; _distributionCache;
private readonly IDataValueCache<EFClientStatistics, double> private readonly IDataValueCache<EFClientStatistics, double> _maxZScoreCache;
_maxZScoreCache;
private readonly IConfigurationHandler<StatsConfiguration> _configurationHandler; private readonly StatsConfiguration _configuration;
private readonly List<long> _serverIds = new List<long>(); private readonly ApplicationConfiguration _appConfig;
private readonly List<long> _serverIds = new();
private const string DistributionCacheKey = nameof(DistributionCacheKey); private const string DistributionCacheKey = nameof(DistributionCacheKey);
private const string MaxZScoreCacheKey = nameof(MaxZScoreCacheKey); private const string MaxZScoreCacheKey = nameof(MaxZScoreCacheKey);
public ServerDistributionCalculator(IDatabaseContextFactory contextFactory, public ServerDistributionCalculator(IDatabaseContextFactory contextFactory,
IDataValueCache<EFClientStatistics, Dictionary<long, Extensions.LogParams>> distributionCache, IDataValueCache<EFClientStatistics, Dictionary<string, Extensions.LogParams>> distributionCache,
IDataValueCache<EFClientStatistics, double> maxZScoreCache, IDataValueCache<EFClientStatistics, double> maxZScoreCache,
IConfigurationHandlerFactory configFactory) StatsConfiguration config, ApplicationConfiguration appConfig)
{ {
_contextFactory = contextFactory; _contextFactory = contextFactory;
_distributionCache = distributionCache; _distributionCache = distributionCache;
_maxZScoreCache = maxZScoreCache; _maxZScoreCache = maxZScoreCache;
_configurationHandler = configFactory.GetConfigurationHandler<StatsConfiguration>("StatsPluginSettings"); _configuration = config;
_appConfig = appConfig;
} }
public async Task Initialize() public async Task Initialize()
{ {
await LoadServers(); await LoadServers();
_distributionCache.SetCacheItem((async (set, token) =>
_distributionCache.SetCacheItem(async (set, token) =>
{ {
await _configurationHandler.BuildAsync(); var validPlayTime = _configuration.TopPlayersMinPlayTime;
var validPlayTime = _configurationHandler.Configuration()?.TopPlayersMinPlayTime ?? 3600 * 3; var distributions = new Dictionary<string, Extensions.LogParams>();
var distributions = new Dictionary<long, Extensions.LogParams>();
await LoadServers(); 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) foreach (var serverId in _serverIds)
{ {
var performance = await set var performances = await iqPerformances.Where(s => s.ServerId == serverId)
.Where(s => s.ServerId == serverId)
.Where(s => s.Skill > 0)
.Where(s => s.EloRating > 0)
.Where(s => s.Client.Level != EFClient.Permission.Banned)
.Where(s => s.TimePlayed >= validPlayTime) .Where(s => s.TimePlayed >= validPlayTime)
.Where(s => s.UpdatedAt >= Extensions.FifteenDaysAgo()) .Where(s => s.UpdatedAt >= Extensions.FifteenDaysAgo())
.Select(s => s.EloRating * 1 / 3.0 + s.Skill * 2 / 3.0).ToListAsync(); .Select(s => s.EloRating * 1 / 3.0 + s.Skill * 2 / 3.0)
var distributionParams = performance.GenerateDistributionParameters(); .ToListAsync(token);
distributions.Add(serverId, distributionParams); 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; 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(); _maxZScoreCache.SetCacheItem(async (set, ids, token) =>
var validPlayTime = _configurationHandler.Configuration()?.TopPlayersMinPlayTime ?? 3600 * 3; {
var validPlayTime = _configuration.TopPlayersMinPlayTime;
var oldestStat = TimeSpan.FromSeconds(_configuration.TopPlayersMinPlayTime);
var performanceBucket = (string)ids.FirstOrDefault();
var zScore = await set if (!string.IsNullOrEmpty(performanceBucket))
.Where(AdvancedClientStatsResourceQueryHelper.GetRankingFunc(validPlayTime)) {
.Where(s => s.Skill > 0) var bucketConfig =
.Where(s => s.EloRating > 0) _configuration.PerformanceBuckets.FirstOrDefault(cfg =>
.GroupBy(stat => stat.ClientId) cfg.Name == performanceBucket) ?? new PerformanceBucketConfiguration();
.Select(group =>
group.Sum(stat => stat.ZScore * stat.TimePlayed) / group.Sum(stat => stat.TimePlayed)) validPlayTime = (int)bucketConfig.ClientMinPlayTime.TotalSeconds;
.MaxAsync(avgZScore => (double?) avgZScore, token); oldestStat = bucketConfig.RankingExpiration;
return zScore ?? 0; }
}, MaxZScoreCacheKey, Utilities.IsDevelopment ? TimeSpan.FromMinutes(5) : TimeSpan.FromMinutes(30));
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 _distributionCache.GetCacheItem(DistributionCacheKey, new CancellationToken());
await _maxZScoreCache.GetCacheItem(MaxZScoreCacheKey, new CancellationToken());
/*foreach (var serverId in _serverIds) /*foreach (var serverId in _serverIds)
{ {
@ -131,16 +173,28 @@ namespace Stats.Client
} }
} }
public async Task<double> GetZScoreForServer(long serverId, double value) public async Task<double> GetZScoreForServerOrBucket(double value, long? serverId = null,
string performanceBucket = null)
{ {
var serverParams = await _distributionCache.GetCacheItem(DistributionCacheKey, new CancellationToken()); if (serverId is null && performanceBucket is null)
if (!serverParams.ContainsKey(serverId))
{ {
return 0.0; return 0.0;
} }
var sdParams = serverParams[serverId]; var serverParams = await _distributionCache.GetCacheItem(DistributionCacheKey, new CancellationToken());
if (sdParams.Sigma == 0) 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; return 0.0;
} }
@ -149,9 +203,9 @@ namespace Stats.Client
return zScore; return zScore;
} }
public async Task<double?> GetRatingForZScore(double? value) public async Task<double?> 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); return maxZScore == 0 ? null : value.GetRatingForZScore(maxZScore);
} }
} }

View File

@ -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);
}

View File

@ -19,6 +19,8 @@ namespace Stats.Config
public int MostKillsClientLimit { get; set; } = 5; public int MostKillsClientLimit { get; set; } = 5;
public bool EnableAdvancedMetrics { get; set; } = true; public bool EnableAdvancedMetrics { get; set; } = true;
public List<PerformanceBucketConfiguration> PerformanceBuckets { get; set; } = new();
public WeaponNameParserConfiguration[] WeaponNameParserConfigurations { get; set; } = { public WeaponNameParserConfiguration[] WeaponNameParserConfigurations { get; set; } = {
new() new()
{ {

View File

@ -145,11 +145,12 @@ namespace Stats.Helpers
}; };
} }
public static Expression<Func<EFClientStatistics, bool>> GetRankingFunc(int minPlayTime, double? zScore = null, public static Expression<Func<EFClientStatistics, bool>> GetRankingFunc(int minPlayTime, TimeSpan expiration, double? zScore = null,
long? serverId = null) long? serverId = null)
{ {
return (stats) => (serverId == null || stats.ServerId == serverId) && var oldestStat = DateTimeOffset.UtcNow.Subtract(expiration);
stats.UpdatedAt >= Extensions.FifteenDaysAgo() && return stats => (serverId == null || stats.ServerId == serverId) &&
stats.UpdatedAt >= oldestStat &&
stats.Client.Level != EFClient.Permission.Banned && stats.Client.Level != EFClient.Permission.Banned &&
stats.TimePlayed >= minPlayTime stats.TimePlayed >= minPlayTime
&& (zScore == null || stats.ZScore > zScore); && (zScore == null || stats.ZScore > zScore);

View File

@ -1,5 +1,4 @@
using IW4MAdmin.Plugins.Stats.Cheat; using IW4MAdmin.Plugins.Stats.Cheat;
using IW4MAdmin.Plugins.Stats.Config;
using IW4MAdmin.Plugins.Stats.Web.Dtos; using IW4MAdmin.Plugins.Stats.Web.Dtos;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using SharedLibraryCore; using SharedLibraryCore;
@ -1185,93 +1184,122 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
{ {
await using var context = _contextFactory.CreateContext(); await using var context = _contextFactory.CreateContext();
var minPlayTime = _config.TopPlayersMinPlayTime; 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<EFClientStatistics>() var performances = await context.Set<EFClientStatistics>()
.AsNoTracking() .AsNoTracking()
.Where(stat => stat.ClientId == clientId) .Where(stat => stat.ClientId == clientId)
.Where(stat => stat.ServerId != serverId) // ignore the one we're currently tracking .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) .Where(stats => stats.TimePlayed >= minPlayTime)
.ToListAsync(); .ToListAsync();
if (clientStats.TimePlayed >= minPlayTime) if (clientStats.TimePlayed >= minPlayTime)
{ {
clientStats.ZScore = await _serverDistributionCalculator.GetZScoreForServer(serverId, await UpdateForServer(clientId, clientStats, context, minPlayTime, oldestStat, serverId);
clientStats.Performance);
var serverRanking = await context.Set<EFClientStatistics>()
.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();
performances.Add(clientStats); performances.Add(clientStats);
} }
if (performances.Any(performance => performance.TimePlayed >= minPlayTime)) if (performances.Any(performance => performance.TimePlayed >= minPlayTime))
{ {
var aggregateZScore = await UpdateAggregateForServerOrBucket(clientId, clientStats, context, performances, minPlayTime,
performances.WeightValueByPlaytime(nameof(EFClientStatistics.ZScore), minPlayTime); oldestStat, performanceBucket);
int? aggregateRanking = await context.Set<EFClientStatistics>()
.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();
} }
} }
private async Task PruneOldRankings(DatabaseContext context, int clientId, long? serverId = null) private async Task UpdateAggregateForServerOrBucket(int clientId, EFClientStatistics clientStats, DatabaseContext context, List<EFClientStatistics> 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<EFClientStatistics>()
.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<EFClientStatistics>()
.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<EFClientRankingHistory>() var totalRankingEntries = await context.Set<EFClientRankingHistory>()
.Where(r => r.ClientId == clientId) .Where(r => r.ClientId == clientId)
.Where(r => r.ServerId == serverId) .Where(r => r.ServerId == serverId)
.Where(r => r.PerformanceBucket == performanceBucket)
.CountAsync(); .CountAsync();
var mostRecent = await context.Set<EFClientRankingHistory>() var mostRecent = await context.Set<EFClientRankingHistory>()
.Where(r => r.ClientId == clientId) .Where(r => r.ClientId == clientId)
.Where(r => r.ServerId == serverId) .Where(r => r.ServerId == serverId)
.Where(r => r.PerformanceBucket == performanceBucket)
.FirstOrDefaultAsync(r => r.Newest); .FirstOrDefaultAsync(r => r.Newest);
if (mostRecent != null) if (mostRecent != null)
@ -1287,6 +1315,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
var lastRating = await context.Set<EFClientRankingHistory>() var lastRating = await context.Set<EFClientRankingHistory>()
.Where(r => r.ClientId == clientId) .Where(r => r.ClientId == clientId)
.Where(r => r.ServerId == serverId) .Where(r => r.ServerId == serverId)
.Where(r => r.PerformanceBucket == performanceBucket)
.OrderBy(r => r.CreatedDateTime) .OrderBy(r => r.CreatedDateTime)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
@ -1413,7 +1442,10 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
} }
clientStats.SPM = Math.Round(clientStats.SPM, 3); clientStats.SPM = Math.Round(clientStats.SPM, 3);
clientStats.Skill = Math.Round((clientStats.SPM * KDRWeight), 3); var skillFunction = client.GetAdditionalProperty<Func<EFClient, EFClientStatistics, double>>("SkillFunction");
clientStats.Skill =
skillFunction?.Invoke(client, clientStats) ?? Math.Round(clientStats.SPM * KDRWeight, 3);
// fixme: how does this happen? // fixme: how does this happen?
if (double.IsNaN(clientStats.SPM) || double.IsNaN(clientStats.Skill)) 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 killSPM = killSpm, KDRWeight, totalPlayTime, SPMAgainstPlayWeight, clientStats, scoreDifference
}); });
clientStats.SPM = 0; clientStats.SPM = 0;
clientStats.Skill = 0; if (skillFunction is null)
{
clientStats.Skill = 0;
}
} }
clientStats.LastStatCalculation = DateTime.UtcNow; clientStats.LastStatCalculation = DateTime.UtcNow;

View File

@ -54,6 +54,7 @@ namespace SharedLibraryCore.Configuration
[LocalizedDisplayName("WEBFRONT_CONFIGURATION_SERVER_CUSTOM_HOSTNAME")] [LocalizedDisplayName("WEBFRONT_CONFIGURATION_SERVER_CUSTOM_HOSTNAME")]
[ConfigurationOptional] [ConfigurationOptional]
public string CustomHostname { get; set; } public string CustomHostname { get; set; }
public string PerformanceBucket { get; set; }
public IBaseConfiguration Generate() public IBaseConfiguration Generate()
{ {