1
0
mirror of https://github.com/RaidMax/IW4M-Admin.git synced 2025-06-10 15:20:48 -05:00

huge commit for advanced stats feature.

broke data out into its own library.
may be breaking changes with existing plugins
This commit is contained in:
RaidMax
2021-03-22 11:09:25 -05:00
parent 267b045883
commit 434392a7e4
505 changed files with 13671 additions and 3271 deletions

View File

@ -1,10 +1,8 @@
using IW4MAdmin.Plugins.Stats.Cheat;
using IW4MAdmin.Plugins.Stats.Config;
using IW4MAdmin.Plugins.Stats.Models;
using IW4MAdmin.Plugins.Stats.Web.Dtos;
using Microsoft.EntityFrameworkCore;
using SharedLibraryCore;
using SharedLibraryCore.Database.Models;
using SharedLibraryCore.Helpers;
using SharedLibraryCore.Interfaces;
using System;
@ -14,8 +12,18 @@ using System.Linq;
using System.Linq.Expressions;
using System.Threading;
using System.Threading.Tasks;
using Data.Abstractions;
using Data.Context;
using Data.Models;
using Data.Models.Client;
using Data.Models.Client.Stats;
using Data.Models.Server;
using Humanizer.Localisation;
using Microsoft.Extensions.Logging;
using Stats.Client.Abstractions;
using Stats.Helpers;
using static IW4MAdmin.Plugins.Stats.Cheat.Detection;
using EFClient = SharedLibraryCore.Database.Models.EFClient;
using ILogger = Microsoft.Extensions.Logging.ILogger;
namespace IW4MAdmin.Plugins.Stats.Helpers
@ -30,21 +38,25 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
private static List<EFServer> serverModels;
public static string CLIENT_STATS_KEY = "ClientStats";
public static string CLIENT_DETECTIONS_KEY = "ClientDetections";
private readonly SemaphoreSlim _addPlayerWaiter = new SemaphoreSlim(1, 1);
private readonly SemaphoreSlim _addPlayerWaiter = new SemaphoreSlim(1, 1);
private readonly IServerDistributionCalculator _serverDistributionCalculator;
public StatManager(ILogger<StatManager> logger, IManager mgr, IDatabaseContextFactory contextFactory, IConfigurationHandler<StatsConfiguration> configHandler)
public StatManager(ILogger<StatManager> logger, IManager mgr, IDatabaseContextFactory contextFactory,
IConfigurationHandler<StatsConfiguration> configHandler,
IServerDistributionCalculator serverDistributionCalculator)
{
_servers = new ConcurrentDictionary<long, ServerStats>();
_log = logger;
_contextFactory = contextFactory;
_configHandler = configHandler;
_serverDistributionCalculator = serverDistributionCalculator;
}
~StatManager()
{
_addPlayerWaiter.Dispose();
}
private void SetupServerIds()
{
using var ctx = _contextFactory.CreateContext(enableTracking: false);
@ -55,10 +67,10 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
{
var fifteenDaysAgo = DateTime.UtcNow.AddDays(-15);
return (r) => r.ServerId == serverId &&
r.When > fifteenDaysAgo &&
r.RatingHistory.Client.Level != EFClient.Permission.Banned &&
r.Newest &&
r.ActivityAmount >= _configHandler.Configuration().TopPlayersMinPlayTime;
r.When > fifteenDaysAgo &&
r.RatingHistory.Client.Level != EFClient.Permission.Banned &&
r.Newest &&
r.ActivityAmount >= _configHandler.Configuration().TopPlayersMinPlayTime;
}
/// <summary>
@ -66,13 +78,23 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
/// </summary>
/// <param name="clientId">client id of the player</param>
/// <returns></returns>
public async Task<int> GetClientOverallRanking(int clientId)
public async Task<int> GetClientOverallRanking(int clientId, long? serverId = null)
{
await using var context = _contextFactory.CreateContext(enableTracking: false);
if (_configHandler.Configuration().EnableAdvancedMetrics)
{
var clientRanking = await context.Set<EFClientRankingHistory>()
.Where(r => r.ClientId == clientId)
.Where(r => r.ServerId == serverId)
.Where(r => r.Newest)
.FirstOrDefaultAsync();
return clientRanking?.Ranking + 1 ?? 0;
}
var clientPerformance = await context.Set<EFRating>()
.Where(r => r.RatingHistory.ClientId == clientId)
.Where(r => r.ServerId == null)
.Where(r => r.ServerId == serverId)
.Where(r => r.Newest)
.Select(r => r.Performance)
.FirstOrDefaultAsync();
@ -90,25 +112,115 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
return 0;
}
public async Task<List<TopStatsInfo>> GetNewTopStats(int start, int count, long? serverId = null)
{
await using var context = _contextFactory.CreateContext(false);
var clientIdsList = await context.Set<EFClientRankingHistory>()
.Where(ranking => ranking.ServerId == serverId)
.Where(ranking => ranking.Client.Level != Data.Models.Client.EFClient.Permission.Banned)
.Where(ranking => ranking.Client.LastConnection >= Extensions.FifteenDaysAgo())
.Where(ranking => ranking.ZScore != null)
.Where(ranking => ranking.PerformanceMetric != null)
.Where(ranking => ranking.Newest)
.Where(ranking =>
ranking.Client.TotalConnectionTime >= _configHandler.Configuration().TopPlayersMinPlayTime)
.OrderByDescending(ranking => ranking.PerformanceMetric)
.Select(ranking => ranking.ClientId)
.Skip(start)
.Take(count)
.ToListAsync();
var rankings = await context.Set<EFClientRankingHistory>()
.Where(ranking => clientIdsList.Contains(ranking.ClientId))
.Where(ranking => ranking.ServerId == serverId)
.Select(ranking => new
{
ranking.ClientId,
ranking.Client.CurrentAlias.Name,
ranking.Client.LastConnection,
ranking.PerformanceMetric,
ranking.ZScore,
ranking.Ranking,
ranking.CreatedDateTime
})
.ToListAsync();
var rankingsDict = rankings.GroupBy(rank => rank.ClientId)
.ToDictionary(rank => rank.Key, rank => rank.OrderBy(r => r.CreatedDateTime).ToList());
var statsInfo = await context.Set<EFClientStatistics>()
.Where(stat => clientIdsList.Contains(stat.ClientId))
.Where(stat => stat.TimePlayed > 0)
.Where(stat => stat.Kills > 0 || stat.Deaths > 0)
.Where(stat => serverId == null || stat.ServerId == serverId)
.GroupBy(stat => stat.ClientId)
.Select(s => new
{
ClientId = s.Key,
Kills = s.Sum(c => c.Kills),
Deaths = s.Sum(c => c.Deaths),
KDR = s.Sum(c => (c.Kills / (double) (c.Deaths == 0 ? 1 : c.Deaths)) * c.TimePlayed) /
s.Sum(c => c.TimePlayed),
TotalTimePlayed = s.Sum(c => c.TimePlayed),
})
.ToListAsync();
var finished = statsInfo
.OrderByDescending(stat => rankingsDict[stat.ClientId].Last().PerformanceMetric)
.Select((s, index) => new TopStatsInfo()
{
ClientId = s.ClientId,
Id = (int?) serverId ?? 0,
Deaths = s.Deaths,
Kills = s.Kills,
KDR = Math.Round(s.KDR, 2),
LastSeen = (DateTime.UtcNow - rankingsDict[s.ClientId].First().LastConnection)
.HumanizeForCurrentCulture(1, TimeUnit.Week, TimeUnit.Second, ",", false),
LastSeenValue = (DateTime.UtcNow - rankingsDict[s.ClientId].First().LastConnection),
Name = rankingsDict[s.ClientId].First().Name,
Performance = Math.Round(rankingsDict[s.ClientId].Last().PerformanceMetric ?? 0, 2),
RatingChange = (rankingsDict[s.ClientId].First().Ranking -
rankingsDict[s.ClientId].Last().Ranking) ?? 0,
PerformanceHistory = rankingsDict[s.ClientId].Select(ranking => ranking.PerformanceMetric ?? 0).ToList(),
TimePlayed = Math.Round(s.TotalTimePlayed / 3600.0, 1).ToString("#,##0"),
TimePlayedValue = TimeSpan.FromSeconds(s.TotalTimePlayed),
Ranking = index + start + 1,
ZScore = rankingsDict[s.ClientId].Last().ZScore,
ServerId = serverId
})
.OrderBy(r => r.Ranking)
.ToList();
return finished;
}
public async Task<List<TopStatsInfo>> GetTopStats(int start, int count, long? serverId = null)
{
if (_configHandler.Configuration().EnableAdvancedMetrics)
{
return await GetNewTopStats(start, count, serverId);
}
await using var context = _contextFactory.CreateContext(enableTracking: false);
// setup the query for the clients within the given rating range
var iqClientRatings = (from rating in context.Set<EFRating>()
.Where(GetRankingFunc(serverId))
select new
{
rating.RatingHistory.ClientId,
rating.RatingHistory.Client.CurrentAlias.Name,
rating.RatingHistory.Client.LastConnection,
rating.Performance,
})
.Where(GetRankingFunc(serverId))
select new
{
rating.RatingHistory.ClientId,
rating.RatingHistory.Client.CurrentAlias.Name,
rating.RatingHistory.Client.LastConnection,
rating.Performance,
})
.OrderByDescending(c => c.Performance)
.Skip(start)
.Take(count);
// materialized list
var clientRatings = await iqClientRatings.ToListAsync();
var clientRatings = (await iqClientRatings.ToListAsync())
.GroupBy(rating => rating.ClientId) // prevent duplicate keys
.Select(group => group.FirstOrDefault());
// get all the unique client ids that are in the top stats
var clientIds = clientRatings
@ -117,59 +229,67 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
.ToList();
var iqRatingInfo = from rating in context.Set<EFRating>()
where clientIds.Contains(rating.RatingHistory.ClientId)
where rating.ServerId == serverId
select new
{
rating.Ranking,
rating.Performance,
rating.RatingHistory.ClientId,
rating.When
};
where clientIds.Contains(rating.RatingHistory.ClientId)
where rating.ServerId == serverId
select new
{
rating.Ranking,
rating.Performance,
rating.RatingHistory.ClientId,
rating.When
};
var ratingInfo = (await iqRatingInfo.ToListAsync())
.GroupBy(r => r.ClientId)
.Select(grp => new
{
grp.Key,
Ratings = grp.Select(r => new { r.Performance, r.Ranking, r.When })
Ratings = grp.Select(r => new {r.Performance, r.Ranking, r.When})
});
var iqStatsInfo = (from stat in context.Set<EFClientStatistics>()
where clientIds.Contains(stat.ClientId)
where stat.Kills > 0 || stat.Deaths > 0
where serverId == null ? true : stat.ServerId == serverId
group stat by stat.ClientId into s
select new
{
ClientId = s.Key,
Kills = s.Sum(c => c.Kills),
Deaths = s.Sum(c => c.Deaths),
KDR = s.Sum(c => (c.Kills / (double)(c.Deaths == 0 ? 1 : c.Deaths)) * c.TimePlayed) / s.Sum(c => c.TimePlayed),
TotalTimePlayed = s.Sum(c => c.TimePlayed),
});
where clientIds.Contains(stat.ClientId)
where stat.Kills > 0 || stat.Deaths > 0
where serverId == null || stat.ServerId == serverId
group stat by stat.ClientId
into s
select new
{
ClientId = s.Key,
Kills = s.Sum(c => c.Kills),
Deaths = s.Sum(c => c.Deaths),
KDR = s.Sum(c => (c.Kills / (double) (c.Deaths == 0 ? 1 : c.Deaths)) * c.TimePlayed) /
s.Sum(c => c.TimePlayed),
TotalTimePlayed = s.Sum(c => c.TimePlayed),
});
var topPlayers = await iqStatsInfo.ToListAsync();
var clientRatingsDict = clientRatings.ToDictionary(r => r.ClientId);
var finished = topPlayers.Select(s => new TopStatsInfo()
{
ClientId = s.ClientId,
Id = (int?)serverId ?? 0,
Deaths = s.Deaths,
Kills = s.Kills,
KDR = Math.Round(s.KDR, 2),
LastSeen = (DateTime.UtcNow - clientRatingsDict[s.ClientId].LastConnection).HumanizeForCurrentCulture(),
Name = clientRatingsDict[s.ClientId].Name,
Performance = Math.Round(clientRatingsDict[s.ClientId].Performance, 2),
RatingChange = ratingInfo.First(r => r.Key == s.ClientId).Ratings.First().Ranking - ratingInfo.First(r => r.Key == s.ClientId).Ratings.Last().Ranking,
PerformanceHistory = ratingInfo.First(r => r.Key == s.ClientId).Ratings.Count() > 1 ?
ratingInfo.First(r => r.Key == s.ClientId).Ratings.OrderBy(r => r.When).Select(r => r.Performance).ToList() :
new List<double>() { clientRatingsDict[s.ClientId].Performance, clientRatingsDict[s.ClientId].Performance },
TimePlayed = Math.Round(s.TotalTimePlayed / 3600.0, 1).ToString("#,##0"),
})
.OrderByDescending(r => r.Performance)
.ToList();
{
ClientId = s.ClientId,
Id = (int?) serverId ?? 0,
Deaths = s.Deaths,
Kills = s.Kills,
KDR = Math.Round(s.KDR, 2),
LastSeen = (DateTime.UtcNow - clientRatingsDict[s.ClientId].LastConnection)
.HumanizeForCurrentCulture(),
LastSeenValue = DateTime.UtcNow - clientRatingsDict[s.ClientId].LastConnection,
Name = clientRatingsDict[s.ClientId].Name,
Performance = Math.Round(clientRatingsDict[s.ClientId].Performance, 2),
RatingChange = ratingInfo.First(r => r.Key == s.ClientId).Ratings.First().Ranking -
ratingInfo.First(r => r.Key == s.ClientId).Ratings.Last().Ranking,
PerformanceHistory = ratingInfo.First(r => r.Key == s.ClientId).Ratings.Count() > 1
? ratingInfo.First(r => r.Key == s.ClientId).Ratings.OrderBy(r => r.When)
.Select(r => r.Performance).ToList()
: new List<double>()
{clientRatingsDict[s.ClientId].Performance, clientRatingsDict[s.ClientId].Performance},
TimePlayed = Math.Round(s.TotalTimePlayed / 3600.0, 1).ToString("#,##0"),
TimePlayedValue = TimeSpan.FromSeconds(s.TotalTimePlayed)
})
.OrderByDescending(r => r.Performance)
.ToList();
// set the ranking numerically
int i = start + 1;
@ -226,7 +346,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
Port = sv.Port,
EndPoint = sv.ToString(),
ServerId = serverId,
GameName = sv.GameName,
GameName = (Reference.Game?) sv.GameName,
HostName = sv.Hostname
};
@ -236,9 +356,9 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
}
// we want to set the gamename up if it's never been set, or it changed
else if (!server.GameName.HasValue || server.GameName.HasValue && server.GameName.Value != sv.GameName)
else if (!server.GameName.HasValue || server.GameName.Value != (Reference.Game) sv.GameName)
{
server.GameName = sv.GameName;
server.GameName = (Reference.Game) sv.GameName;
ctx.Entry(server).Property(_prop => _prop.GameName).IsModified = true;
ctx.SaveChanges();
}
@ -265,7 +385,8 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
catch (Exception e)
{
_log.LogError(e, "{message}", Utilities.CurrentLocalization.LocalizationIndex["PLUGIN_STATS_ERROR_ADD"]);
_log.LogError(e, "{message}",
Utilities.CurrentLocalization.LocalizationIndex["PLUGIN_STATS_ERROR_ADD"]);
}
}
@ -277,7 +398,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
public async Task<EFClientStatistics> AddPlayer(EFClient pl)
{
var existingStats = pl.GetAdditionalProperty<EFClientStatistics>(CLIENT_STATS_KEY);
if (existingStats != null)
{
return existingStats;
@ -322,7 +443,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
{
Active = true,
HitCount = 0,
Location = hl
Location = (int) hl
}).ToList()
};
@ -342,7 +463,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
{
Active = true,
HitCount = 0,
Location = hl
Location = (int) hl
})
.ToList();
@ -404,7 +525,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
return;
}
long serverId = GetIdForServer(pl.CurrentServer);
var serverId = GetIdForServer(pl.CurrentServer);
var serverStats = _servers[serverId].ServerStatistics;
// get individual client's stats
@ -414,9 +535,14 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
{
clientStats = UpdateStats(clientStats);
await SaveClientStats(clientStats);
if (_configHandler.Configuration().EnableAdvancedMetrics)
{
await UpdateHistoricalRanking(pl.ClientId, clientStats, serverId);
}
// increment the total play time
serverStats.TotalPlayTime += pl.ConnectionLength;
pl.SetAdditionalProperty(CLIENT_STATS_KEY, null);
}
else
@ -440,8 +566,10 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
/// Process stats for kill event
/// </summary>
/// <returns></returns>
public async Task AddScriptHit(bool isDamage, DateTime time, EFClient attacker, EFClient victim, long serverId, string map, string hitLoc, string type,
string damage, string weapon, string killOrigin, string deathOrigin, string viewAngles, string offset, string isKillstreakKill, string Ads,
public async Task AddScriptHit(bool isDamage, DateTime time, EFClient attacker, EFClient victim, long serverId,
string map, string hitLoc, string type,
string damage, string weapon, string killOrigin, string deathOrigin, string viewAngles, string offset,
string isKillstreakKill, string Ads,
string fraction, string visibilityPercentage, string snapAngles, string isAlive, string lastAttackTime)
{
Vector3 vDeathOrigin = null;
@ -478,25 +606,26 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
ServerId = serverId,
DeathOrigin = vDeathOrigin,
KillOrigin = vKillOrigin,
DeathType = ParseEnum<IW4Info.MeansOfDeath>.Get(type, typeof(IW4Info.MeansOfDeath)),
DeathType = (int) ParseEnum<IW4Info.MeansOfDeath>.Get(type, typeof(IW4Info.MeansOfDeath)),
Damage = int.Parse(damage),
HitLoc = ParseEnum<IW4Info.HitLocation>.Get(hitLoc, typeof(IW4Info.HitLocation)),
Weapon = ParseEnum<IW4Info.WeaponName>.Get(weapon, typeof(IW4Info.WeaponName)),
HitLoc = (int) ParseEnum<IW4Info.HitLocation>.Get(hitLoc, typeof(IW4Info.HitLocation)),
Weapon = (int) ParseEnum<IW4Info.WeaponName>.Get(weapon, typeof(IW4Info.WeaponName)),
ViewAngles = vViewAngles,
TimeOffset = long.Parse(offset),
When = time,
IsKillstreakKill = isKillstreakKill[0] != '0',
AdsPercent = float.Parse(Ads, System.Globalization.CultureInfo.InvariantCulture),
Fraction = double.Parse(fraction, System.Globalization.CultureInfo.InvariantCulture),
VisibilityPercentage = double.Parse(visibilityPercentage, System.Globalization.CultureInfo.InvariantCulture),
VisibilityPercentage = double.Parse(visibilityPercentage,
System.Globalization.CultureInfo.InvariantCulture),
IsKill = !isDamage,
AnglesList = snapshotAngles,
IsAlive = isAlive == "1",
TimeSinceLastAttack = long.Parse(lastAttackTime),
GameName = attacker.CurrentServer.GameName
GameName = (int) attacker.CurrentServer.GameName
};
if (hit.HitLoc == IW4Info.HitLocation.shield)
if (hit.HitLoc == (int) IW4Info.HitLocation.shield)
{
// we don't care about shield hits
return;
@ -509,9 +638,9 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
await waiter.WaitAsync(Utilities.DefaultCommandTimeout, Plugin.ServerManager.CancellationToken);
// increment their hit count
if (hit.DeathType == IW4Info.MeansOfDeath.MOD_PISTOL_BULLET ||
hit.DeathType == IW4Info.MeansOfDeath.MOD_RIFLE_BULLET ||
hit.DeathType == IW4Info.MeansOfDeath.MOD_HEAD_SHOT)
if (hit.DeathType == (int) IW4Info.MeansOfDeath.MOD_PISTOL_BULLET ||
hit.DeathType == (int) IW4Info.MeansOfDeath.MOD_RIFLE_BULLET ||
hit.DeathType == (int) IW4Info.MeansOfDeath.MOD_HEAD_SHOT)
{
clientStats.HitLocations.First(hl => hl.Location == hit.HitLoc).HitCount += 1;
}
@ -550,7 +679,8 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
}
}
if (Plugin.Config.Configuration().AnticheatConfiguration.Enable && !attacker.IsBot && attacker.ClientId != victim.ClientId)
if (Plugin.Config.Configuration().AnticheatConfiguration.Enable && !attacker.IsBot &&
attacker.ClientId != victim.ClientId)
{
clientDetection.TrackedHits.Add(hit);
@ -573,7 +703,8 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
await ApplyPenalty(result, attacker);
}
if (clientDetection.Tracker.HasChanges && result.ClientPenalty != EFPenalty.PenaltyType.Any)
if (clientDetection.Tracker.HasChanges &&
result.ClientPenalty != EFPenalty.PenaltyType.Any)
{
await SaveTrackedSnapshots(clientDetection);
@ -590,10 +721,13 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
}
}
catch (TaskCanceledException) { }
catch (TaskCanceledException)
{
}
catch (Exception ex)
{
_log.LogError(ex, "Could not save hit or anti-cheat info {@attacker} {@victim} {server}", attacker, victim, serverId);
_log.LogError(ex, "Could not save hit or anti-cheat info {@attacker} {@victim} {server}", attacker,
victim, serverId);
}
finally
@ -605,16 +739,17 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
}
}
private DetectionPenaltyResult DeterminePenaltyResult(IEnumerable<DetectionPenaltyResult> results, EFClient client)
private DetectionPenaltyResult DeterminePenaltyResult(IEnumerable<DetectionPenaltyResult> results,
EFClient client)
{
// allow disabling of certain detection types
results = results.Where(_result => ShouldUseDetection(client.CurrentServer, _result.Type, client.ClientId));
return results.FirstOrDefault(_result => _result.ClientPenalty == EFPenalty.PenaltyType.Ban) ??
results.FirstOrDefault(_result => _result.ClientPenalty == EFPenalty.PenaltyType.Flag) ??
new DetectionPenaltyResult()
{
ClientPenalty = EFPenalty.PenaltyType.Any,
};
results.FirstOrDefault(_result => _result.ClientPenalty == EFPenalty.PenaltyType.Flag) ??
new DetectionPenaltyResult()
{
ClientPenalty = EFPenalty.PenaltyType.Any,
};
}
public async Task SaveHitCache(long serverId)
@ -647,7 +782,6 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
catch (KeyNotFoundException)
{
}
return true;
@ -668,13 +802,16 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
{
new EFPenalty()
{
AutomatedOffense = penalty.Type == Detection.DetectionType.Bone ?
$"{penalty.Type}-{(int)penalty.Location}-{Math.Round(penalty.Value, 2)}@{penalty.HitCount}" :
$"{penalty.Type}-{Math.Round(penalty.Value, 2)}@{penalty.HitCount}",
AutomatedOffense = penalty.Type == Detection.DetectionType.Bone
? $"{penalty.Type}-{(int) penalty.Location}-{Math.Round(penalty.Value, 2)}@{penalty.HitCount}"
: $"{penalty.Type}-{Math.Round(penalty.Value, 2)}@{penalty.HitCount}",
}
};
await attacker.Ban(Utilities.CurrentLocalization.LocalizationIndex["PLUGIN_STATS_CHEAT_DETECTED"], penaltyClient, false).WaitAsync(Utilities.DefaultCommandTimeout, attacker.CurrentServer.Manager.CancellationToken);
await attacker
.Ban(Utilities.CurrentLocalization.LocalizationIndex["PLUGIN_STATS_CHEAT_DETECTED"],
penaltyClient, false).WaitAsync(Utilities.DefaultCommandTimeout,
attacker.CurrentServer.Manager.CancellationToken);
break;
case EFPenalty.PenaltyType.Flag:
if (attacker.Level != EFClient.Permission.User)
@ -682,9 +819,9 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
break;
}
string flagReason = penalty.Type == Cheat.Detection.DetectionType.Bone ?
$"{penalty.Type}-{(int)penalty.Location}-{Math.Round(penalty.Value, 2)}@{penalty.HitCount}" :
$"{penalty.Type}-{Math.Round(penalty.Value, 2)}@{penalty.HitCount}";
string flagReason = penalty.Type == Cheat.Detection.DetectionType.Bone
? $"{penalty.Type}-{(int) penalty.Location}-{Math.Round(penalty.Value, 2)}@{penalty.HitCount}"
: $"{penalty.Type}-{Math.Round(penalty.Value, 2)}@{penalty.HitCount}";
penaltyClient.AdministeredPenalties = new List<EFPenalty>()
{
@ -694,7 +831,8 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
}
};
await attacker.Flag(flagReason, penaltyClient, new TimeSpan(168, 0, 0)).WaitAsync(Utilities.DefaultCommandTimeout, attacker.CurrentServer.Manager.CancellationToken);
await attacker.Flag(flagReason, penaltyClient, new TimeSpan(168, 0, 0))
.WaitAsync(Utilities.DefaultCommandTimeout, attacker.CurrentServer.Manager.CancellationToken);
break;
}
}
@ -708,6 +846,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
{
ctx.Add(change);
}
await ctx.SaveChangesAsync();
}
@ -743,9 +882,9 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
victimStats.LastScore = victim.Score;
// show encouragement/discouragement
string streakMessage = (attackerStats.ClientId != victimStats.ClientId) ?
StreakMessage.MessageOnStreak(attackerStats.KillStreak, attackerStats.DeathStreak) :
StreakMessage.MessageOnStreak(-1, -1);
string streakMessage = (attackerStats.ClientId != victimStats.ClientId)
? StreakMessage.MessageOnStreak(attackerStats.KillStreak, attackerStats.DeathStreak)
: StreakMessage.MessageOnStreak(-1, -1);
if (streakMessage != string.Empty)
{
@ -768,15 +907,26 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
}
// update their performance
if ((DateTime.UtcNow - attackerStats.LastStatHistoryUpdate).TotalMinutes >= 2.5)
if ((DateTime.UtcNow - attackerStats.LastStatHistoryUpdate).TotalMinutes >=
(Utilities.IsDevelopment ? 0.5 : _configHandler.Configuration().EnableAdvancedMetrics ? 10.0 : 2.5))
{
try
{
// kill event is not designated as blocking, so we should be able to enter and exit
// we need to make this thread safe because we can potentially have kills qualify
// for stat history update, but one is already processing that invalidates the original
await attackerStats.ProcessingHit.WaitAsync(Utilities.DefaultCommandTimeout, Plugin.ServerManager.CancellationToken);
await UpdateStatHistory(attacker, attackerStats);
await attackerStats.ProcessingHit.WaitAsync(Utilities.DefaultCommandTimeout,
Plugin.ServerManager.CancellationToken);
if (_configHandler.Configuration().EnableAdvancedMetrics)
{
await UpdateHistoricalRanking(attacker.ClientId, attackerStats, serverId);
}
else
{
await UpdateStatHistory(attacker, attackerStats);
}
attackerStats.LastStatHistoryUpdate = DateTime.UtcNow;
}
@ -796,14 +946,14 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
}
/// <summary>
/// Update the invidual and average stat history for a client
/// Update the individual and average stat history for a client
/// </summary>
/// <param name="client">client to update</param>
/// <param name="clientStats">stats of client that is being updated</param>
/// <returns></returns>
public async Task UpdateStatHistory(EFClient client, EFClientStatistics clientStats)
{
int currentSessionTime = (int)(DateTime.UtcNow - client.LastConnection).TotalSeconds;
int currentSessionTime = (int) (DateTime.UtcNow - client.LastConnection).TotalSeconds;
// don't update their stat history if they haven't played long
if (currentSessionTime < 60)
@ -816,18 +966,18 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
await using var ctx = _contextFactory.CreateContext(enableTracking: true);
// select the rating history for client
var iqHistoryLink = from history in ctx.Set<EFClientRatingHistory>()
.Include(h => h.Ratings)
where history.ClientId == client.ClientId
select history;
.Include(h => h.Ratings)
where history.ClientId == client.ClientId
select history;
// get the client ratings
var clientHistory = await iqHistoryLink
.FirstOrDefaultAsync() ?? new EFClientRatingHistory()
{
Active = true,
ClientId = client.ClientId,
Ratings = new List<EFRating>()
};
{
Active = true,
ClientId = client.ClientId,
Ratings = new List<EFRating>()
};
// it's the first time they've played
if (clientHistory.RatingHistoryId == 0)
@ -836,13 +986,14 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
}
#region INDIVIDUAL_SERVER_PERFORMANCE
// get the client ranking for the current server
int individualClientRanking = await ctx.Set<EFRating>()
.Where(GetRankingFunc(clientStats.ServerId))
// ignore themselves in the query
.Where(c => c.RatingHistory.ClientId != client.ClientId)
.Where(c => c.Performance > clientStats.Performance)
.CountAsync() + 1;
.Where(GetRankingFunc(clientStats.ServerId))
// ignore themselves in the query
.Where(c => c.RatingHistory.ClientId != client.ClientId)
.Where(c => c.Performance > clientStats.Performance)
.CountAsync() + 1;
// limit max history per server to 40
if (clientHistory.Ratings.Count(r => r.ServerId == clientStats.ServerId) >= 40)
@ -887,16 +1038,18 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
ctx.Add(newServerRating);
#endregion
#region OVERALL_RATING
// select all performance & time played for current client
var iqClientStats = from stats in ctx.Set<EFClientStatistics>()
where stats.ClientId == client.ClientId
where stats.ServerId != clientStats.ServerId
select new
{
stats.Performance,
stats.TimePlayed
};
where stats.ClientId == client.ClientId
where stats.ServerId != clientStats.ServerId
select new
{
stats.Performance,
stats.TimePlayed
};
var clientStatsList = await iqClientStats.ToListAsync();
@ -908,7 +1061,8 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
});
// weight the overall performance based on play time
double performanceAverage = clientStatsList.Sum(p => (p.Performance * p.TimePlayed)) / clientStatsList.Sum(p => p.TimePlayed);
double performanceAverage = clientStatsList.Sum(p => (p.Performance * p.TimePlayed)) /
clientStatsList.Sum(p => p.TimePlayed);
// shouldn't happen but just in case the sum of time played is 0
if (double.IsNaN(performanceAverage))
@ -917,10 +1071,10 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
}
int overallClientRanking = await ctx.Set<EFRating>()
.Where(GetRankingFunc())
.Where(r => r.RatingHistory.ClientId != client.ClientId)
.Where(r => r.Performance > performanceAverage)
.CountAsync() + 1;
.Where(GetRankingFunc())
.Where(r => r.RatingHistory.ClientId != client.ClientId)
.Where(r => r.Performance > performanceAverage)
.CountAsync() + 1;
// limit max average history to 40
if (clientHistory.Ratings.Count(r => r.ServerId == null) >= 40)
@ -962,11 +1116,112 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
};
ctx.Add(averageRating);
#endregion
await ctx.SaveChangesAsync();
}
public async Task UpdateHistoricalRanking(int clientId, EFClientStatistics clientStats, long serverId)
{
await using var context = _contextFactory.CreateContext();
var performances = await context.Set<EFClientStatistics>()
.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.TimePlayed >= _configHandler.Configuration().TopPlayersMinPlayTime)
.ToListAsync();
if (clientStats.TimePlayed >= _configHandler.Configuration().TopPlayersMinPlayTime)
{
clientStats.ZScore = await _serverDistributionCalculator.GetZScoreForServer(serverId,
clientStats.Performance);
var serverRanking = await context.Set<EFClientStatistics>()
.Where(stats => stats.ClientId != clientStats.ClientId)
.Where(AdvancedClientStatsResourceQueryHelper.GetRankingFunc(
_configHandler.Configuration().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);
}
if (performances.Any(performance => performance.TimePlayed >= _configHandler.Configuration().TopPlayersMinPlayTime))
{
var aggregateZScore = performances.WeightValueByPlaytime(nameof(EFClientStatistics.ZScore), _configHandler.Configuration().TopPlayersMinPlayTime);
int? aggregateRanking = await context.Set<EFClientStatistics>()
.Where(stat => stat.ClientId != clientId)
.Where(AdvancedClientStatsResourceQueryHelper.GetRankingFunc(_configHandler.Configuration()
.TopPlayersMinPlayTime))
.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 aggregateRankingSnapshot = new EFClientRankingHistory()
{
ClientId = clientId,
ZScore = aggregateZScore,
Ranking = aggregateRanking,
PerformanceMetric = await _serverDistributionCalculator.GetRatingForZScore(aggregateZScore),
Newest = true,
};
context.Add(aggregateRankingSnapshot);
await PruneOldRankings(context, clientId);
await context.SaveChangesAsync();
}
}
private async Task PruneOldRankings(DatabaseContext context, int clientId, long? serverId = null)
{
var totalRankingEntries = await context.Set<EFClientRankingHistory>()
.Where(r => r.ClientId == clientId)
.Where(r => r.ServerId == serverId)
.CountAsync();
var mostRecent = await context.Set<EFClientRankingHistory>()
.Where(r => r.ClientId == clientId)
.Where(r => r.ServerId == serverId)
.FirstOrDefaultAsync(r => r.Newest);
if (mostRecent != null)
{
mostRecent.Newest = false;
context.Update(mostRecent);
}
if (totalRankingEntries > EFClientRankingHistory.MaxRankingCount)
{
var lastRating = await context.Set<EFClientRankingHistory>()
.Where(r => r.ClientId == clientId)
.Where(r => r.ServerId == serverId)
.OrderBy(r => r.CreatedDateTime)
.FirstOrDefaultAsync();
context.Remove(lastRating);
}
}
/// <summary>
/// Performs the incrementation of kills and deaths for client statistics
/// </summary>
@ -994,6 +1249,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
attackerStats = UpdateStats(attackerStats);
#region DEPRECATED
/* var validAttackerLobbyRatings = Servers[attackerStats.ServerId].PlayerStats
.Where(cs => cs.Value.ClientId != attackerStats.ClientId)
.Where(cs =>
@ -1017,10 +1273,12 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
double victimLobbyRating = validVictimLobbyRatings.Count() > 0 ?
validVictimLobbyRatings.Average(cs => cs.Value.EloRating) :
victimStats.EloRating;*/
#endregion
// calculate elo
double attackerEloDifference = Math.Log(Math.Max(1, victimStats.EloRating)) - Math.Log(Math.Max(1, attackerStats.EloRating));
double attackerEloDifference = Math.Log(Math.Max(1, victimStats.EloRating)) -
Math.Log(Math.Max(1, attackerStats.EloRating));
double winPercentage = 1.0 / (1 + Math.Pow(10, attackerEloDifference / Math.E));
// double victimEloDifference = Math.Log(Math.Max(1, attackerStats.EloRating)) - Math.Log(Math.Max(1, victimStats.EloRating));
@ -1033,8 +1291,8 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
victimStats.EloRating = Math.Max(0, Math.Round(victimStats.EloRating, 2));
// update after calculation
attackerStats.TimePlayed += (int)(DateTime.UtcNow - attackerStats.LastActive).TotalSeconds;
victimStats.TimePlayed += (int)(DateTime.UtcNow - victimStats.LastActive).TotalSeconds;
attackerStats.TimePlayed += (int) (DateTime.UtcNow - attackerStats.LastActive).TotalSeconds;
victimStats.TimePlayed += (int) (DateTime.UtcNow - victimStats.LastActive).TotalSeconds;
attackerStats.LastActive = DateTime.UtcNow;
victimStats.LastActive = DateTime.UtcNow;
}
@ -1072,7 +1330,12 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
}
double killSPM = scoreDifference / timeSinceLastCalc;
double spmMultiplier = 2.934 * Math.Pow(_servers[clientStats.ServerId].TeamCount(clientStats.Team == IW4Info.Team.Allies ? IW4Info.Team.Axis : IW4Info.Team.Allies), -0.454);
double spmMultiplier = 2.934 *
Math.Pow(
_servers[clientStats.ServerId]
.TeamCount((IW4Info.Team) clientStats.Team == IW4Info.Team.Allies
? IW4Info.Team.Axis
: IW4Info.Team.Allies), -0.454);
killSPM *= Math.Max(1, spmMultiplier);
// update this for ac tracking
@ -1080,15 +1343,17 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
// calculate how much the KDR should weigh
// 1.637 is a Eddie-Generated number that weights the KDR nicely
double currentKDR = clientStats.SessionDeaths == 0 ? clientStats.SessionKills : clientStats.SessionKills / clientStats.SessionDeaths;
double currentKDR = clientStats.SessionDeaths == 0
? clientStats.SessionKills
: clientStats.SessionKills / clientStats.SessionDeaths;
double alpha = Math.Sqrt(2) / Math.Min(600, Math.Max(clientStats.Kills + clientStats.Deaths, 1));
clientStats.RollingWeightedKDR = (alpha * currentKDR) + (1.0 - alpha) * clientStats.KDR;
double KDRWeight = Math.Round(Math.Pow(clientStats.RollingWeightedKDR, 1.637 / Math.E), 3);
// calculate the weight of the new play time against last 10 hours of gameplay
int totalPlayTime = (clientStats.TimePlayed == 0) ?
(int)(DateTime.UtcNow - clientStats.LastActive).TotalSeconds :
clientStats.TimePlayed + (int)(DateTime.UtcNow - clientStats.LastActive).TotalSeconds;
int totalPlayTime = (clientStats.TimePlayed == 0)
? (int) (DateTime.UtcNow - clientStats.LastActive).TotalSeconds
: clientStats.TimePlayed + (int) (DateTime.UtcNow - clientStats.LastActive).TotalSeconds;
double SPMAgainstPlayWeight = timeSinceLastCalc / Math.Min(600, (totalPlayTime / 60.0));
@ -1107,13 +1372,15 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
// fixme: how does this happen?
if (double.IsNaN(clientStats.SPM) || double.IsNaN(clientStats.Skill))
{
_log.LogWarning("clientStats SPM/Skill NaN {@killInfo}", new {killSPM, KDRWeight, totalPlayTime, SPMAgainstPlayWeight, clientStats, scoreDifference});
_log.LogWarning("clientStats SPM/Skill NaN {@killInfo}",
new {killSPM, KDRWeight, totalPlayTime, SPMAgainstPlayWeight, clientStats, scoreDifference});
clientStats.SPM = 0;
clientStats.Skill = 0;
}
clientStats.LastStatCalculation = DateTime.UtcNow;
//clientStats.LastScore = clientStats.SessionScore;
clientStats.UpdatedAt = DateTime.UtcNow;
return clientStats;
}
@ -1161,13 +1428,13 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
public void ResetStats(EFClient client)
{
var stats = client.GetAdditionalProperty<EFClientStatistics>(CLIENT_STATS_KEY);
// the cached stats have not been loaded yet
if (stats == null)
{
return;
}
stats.Kills = 0;
stats.Deaths = 0;
stats.SPM = 0;
@ -1253,8 +1520,8 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
long? serverId;
serverId = serverModels.FirstOrDefault(_server => _server.ServerId == server.EndPoint ||
_server.EndPoint == server.ToString() ||
_server.ServerId == id)?.ServerId;
_server.EndPoint == server.ToString() ||
_server.ServerId == id)?.ServerId;
if (!serverId.HasValue)
{
@ -1264,4 +1531,4 @@ namespace IW4MAdmin.Plugins.Stats.Helpers
return serverId.Value;
}
}
}
}