1653 lines
66 KiB
C#
1653 lines
66 KiB
C#
using IW4MAdmin.Plugins.Stats.Cheat;
|
|
using IW4MAdmin.Plugins.Stats.Config;
|
|
using IW4MAdmin.Plugins.Stats.Web.Dtos;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using SharedLibraryCore;
|
|
using SharedLibraryCore.Helpers;
|
|
using SharedLibraryCore.Interfaces;
|
|
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
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.Data.Sqlite;
|
|
using Microsoft.Extensions.Logging;
|
|
using MySqlConnector;
|
|
using Npgsql;
|
|
using Stats.Client.Abstractions;
|
|
using Stats.Config;
|
|
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
|
|
{
|
|
public class StatManager
|
|
{
|
|
private const int MAX_CACHED_HITS = 100;
|
|
private readonly ConcurrentDictionary<long, ServerStats> _servers;
|
|
private readonly ILogger _log;
|
|
private readonly IDatabaseContextFactory _contextFactory;
|
|
private readonly StatsConfiguration _config;
|
|
private static List<EFServer> serverModels;
|
|
public static string CLIENT_STATS_KEY = "ClientStats";
|
|
public static string CLIENT_DETECTIONS_KEY = "ClientDetections";
|
|
public static string ESTIMATED_SCORE = "EstimatedScore";
|
|
private readonly SemaphoreSlim _addPlayerWaiter = new SemaphoreSlim(1, 1);
|
|
private readonly IServerDistributionCalculator _serverDistributionCalculator;
|
|
|
|
public StatManager(ILogger<StatManager> logger, IManager mgr, IDatabaseContextFactory contextFactory,
|
|
StatsConfiguration statsConfig,
|
|
IServerDistributionCalculator serverDistributionCalculator)
|
|
{
|
|
_servers = new ConcurrentDictionary<long, ServerStats>();
|
|
_log = logger;
|
|
_contextFactory = contextFactory;
|
|
_config = statsConfig;
|
|
_serverDistributionCalculator = serverDistributionCalculator;
|
|
}
|
|
|
|
~StatManager()
|
|
{
|
|
_addPlayerWaiter.Dispose();
|
|
}
|
|
|
|
private void SetupServerIds()
|
|
{
|
|
using var ctx = _contextFactory.CreateContext(enableTracking: false);
|
|
serverModels = ctx.Set<EFServer>().ToList();
|
|
}
|
|
|
|
public Expression<Func<EFRating, bool>> GetRankingFunc(long? serverId = null)
|
|
{
|
|
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 >= _config.TopPlayersMinPlayTime;
|
|
}
|
|
|
|
/// <summary>
|
|
/// gets a ranking across all servers for given client id
|
|
/// </summary>
|
|
/// <param name="clientId">client id of the player</param>
|
|
/// <returns></returns>
|
|
public async Task<int> GetClientOverallRanking(int clientId, long? serverId = null)
|
|
{
|
|
await using var context = _contextFactory.CreateContext(enableTracking: false);
|
|
|
|
if (_config.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 == serverId)
|
|
.Where(r => r.Newest)
|
|
.Select(r => r.Performance)
|
|
.FirstOrDefaultAsync();
|
|
|
|
if (clientPerformance != 0)
|
|
{
|
|
var iqClientRanking = context.Set<EFRating>()
|
|
.Where(r => r.RatingHistory.ClientId != clientId)
|
|
.Where(r => r.Performance > clientPerformance)
|
|
.Where(GetRankingFunc());
|
|
|
|
return await iqClientRanking.CountAsync() + 1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
public Expression<Func<EFClientRankingHistory, bool>> GetNewRankingFunc(int? clientId = null,
|
|
long? serverId = null)
|
|
{
|
|
return (ranking) => ranking.ServerId == serverId
|
|
&& ranking.Client.Level != Data.Models.Client.EFClient.Permission.Banned
|
|
&& ranking.CreatedDateTime >= Extensions.FifteenDaysAgo()
|
|
&& ranking.ZScore != null
|
|
&& ranking.PerformanceMetric != null
|
|
&& ranking.Newest
|
|
&& ranking.Client.TotalConnectionTime >=
|
|
_config.TopPlayersMinPlayTime;
|
|
}
|
|
|
|
public async Task<int> GetTotalRankedPlayers(long serverId)
|
|
{
|
|
await using var context = _contextFactory.CreateContext(enableTracking: false);
|
|
|
|
return await context.Set<EFClientRankingHistory>()
|
|
.Where(GetNewRankingFunc(serverId: serverId))
|
|
.CountAsync();
|
|
}
|
|
|
|
public class RankingSnapshot
|
|
{
|
|
public int ClientId { get; set; }
|
|
public string Name { get; set; }
|
|
public DateTime LastConnection { get; set; }
|
|
public double? PerformanceMetric { get; set; }
|
|
public double? ZScore { get; set; }
|
|
public int? Ranking { get; set; }
|
|
public DateTime CreatedDateTime { get; set; }
|
|
}
|
|
|
|
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(GetNewRankingFunc(serverId: serverId))
|
|
.OrderByDescending(ranking => ranking.PerformanceMetric)
|
|
.Select(ranking => ranking.ClientId)
|
|
.Skip(start)
|
|
.Take(count)
|
|
.ToListAsync();
|
|
|
|
var rankingsDict = new Dictionary<int, List<RankingSnapshot>>();
|
|
|
|
foreach (var clientId in clientIdsList)
|
|
{
|
|
var eachRank = await context.Set<EFClientRankingHistory>()
|
|
.Where(ranking => ranking.ClientId == clientId)
|
|
.Where(ranking => ranking.ServerId == serverId)
|
|
.OrderByDescending(ranking => ranking.CreatedDateTime)
|
|
.Select(ranking => new RankingSnapshot
|
|
{
|
|
ClientId = ranking.ClientId,
|
|
Name = ranking.Client.CurrentAlias.Name,
|
|
LastConnection = ranking.Client.LastConnection,
|
|
PerformanceMetric = ranking.PerformanceMetric,
|
|
ZScore = ranking.ZScore,
|
|
Ranking = ranking.Ranking,
|
|
CreatedDateTime = ranking.CreatedDateTime
|
|
})
|
|
.Take(60)
|
|
.ToListAsync();
|
|
|
|
if (rankingsDict.ContainsKey(clientId))
|
|
{
|
|
rankingsDict[clientId] = rankingsDict[clientId].Concat(eachRank).Distinct()
|
|
.OrderByDescending(ranking => ranking.CreatedDateTime).ToList();
|
|
}
|
|
else
|
|
{
|
|
rankingsDict.Add(clientId, eachRank);
|
|
}
|
|
}
|
|
|
|
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),
|
|
UpdatedAt = s.Max(c => c.UpdatedAt)
|
|
})
|
|
.ToListAsync();
|
|
|
|
var finished = statsInfo
|
|
.OrderByDescending(stat => rankingsDict[stat.ClientId].First().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 - (s.UpdatedAt ?? rankingsDict[s.ClientId].First().LastConnection))
|
|
.HumanizeForCurrentCulture(1, TimeUnit.Week, TimeUnit.Second, ",", false),
|
|
LastSeenValue = DateTime.UtcNow - (s.UpdatedAt ?? rankingsDict[s.ClientId].First().LastConnection),
|
|
Name = rankingsDict[s.ClientId].First().Name,
|
|
Performance = Math.Round(rankingsDict[s.ClientId].First().PerformanceMetric ?? 0, 2),
|
|
RatingChange = (rankingsDict[s.ClientId].Last().Ranking -
|
|
rankingsDict[s.ClientId].First().Ranking) ?? 0,
|
|
PerformanceHistory = rankingsDict[s.ClientId].Select(ranking => new PerformanceHistory
|
|
{ Performance = ranking.PerformanceMetric ?? 0, OccurredAt = ranking.CreatedDateTime })
|
|
.ToList(),
|
|
TimePlayed = Math.Round(s.TotalTimePlayed / 3600.0, 1).ToString("#,##0"),
|
|
TimePlayedValue = TimeSpan.FromSeconds(s.TotalTimePlayed),
|
|
Ranking = index + start + 1,
|
|
ZScore = rankingsDict[s.ClientId].First().ZScore,
|
|
ServerId = serverId
|
|
})
|
|
.OrderBy(r => r.Ranking)
|
|
.ToList();
|
|
|
|
return finished;
|
|
}
|
|
|
|
public async Task<List<TopStatsInfo>> GetTopStats(int start, int count, long? serverId = null)
|
|
{
|
|
if (_config.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,
|
|
})
|
|
.OrderByDescending(c => c.Performance)
|
|
.Skip(start)
|
|
.Take(count);
|
|
|
|
// materialized list
|
|
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
|
|
.GroupBy(r => r.ClientId)
|
|
.Select(r => r.First().ClientId)
|
|
.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
|
|
};
|
|
|
|
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 })
|
|
});
|
|
|
|
var iqStatsInfo = (from stat in context.Set<EFClientStatistics>()
|
|
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(),
|
|
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 => new PerformanceHistory { Performance = r.Performance, OccurredAt = r.When })
|
|
.ToList()
|
|
: new List<PerformanceHistory>
|
|
{
|
|
new()
|
|
{
|
|
Performance = clientRatingsDict[s.ClientId].Performance, OccurredAt = DateTime.UtcNow
|
|
},
|
|
new()
|
|
{
|
|
Performance = clientRatingsDict[s.ClientId].Performance, OccurredAt = DateTime.UtcNow
|
|
}
|
|
},
|
|
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;
|
|
foreach (var stat in finished)
|
|
{
|
|
stat.Ranking = i;
|
|
i++;
|
|
}
|
|
|
|
return finished;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Add a server to the StatManager server pool
|
|
/// </summary>
|
|
/// <param name="sv"></param>
|
|
public void AddServer(Server sv)
|
|
{
|
|
// insert the server if it does not exist
|
|
try
|
|
{
|
|
if (serverModels == null)
|
|
{
|
|
SetupServerIds();
|
|
}
|
|
|
|
long serverId = GetIdForServer(sv);
|
|
EFServer server;
|
|
|
|
using var ctx = _contextFactory.CreateContext(enableTracking: false);
|
|
var serverSet = ctx.Set<EFServer>();
|
|
// get the server from the database if it exists, otherwise create and insert a new one
|
|
server = serverSet.FirstOrDefault(s => s.ServerId == serverId);
|
|
|
|
// the server might be using legacy server id
|
|
if (server == null)
|
|
{
|
|
server = serverSet.FirstOrDefault(s => s.EndPoint == sv.ToString());
|
|
|
|
if (server != null)
|
|
{
|
|
// this provides a way to identify legacy server entries
|
|
server.EndPoint = sv.ToString();
|
|
ctx.Update(server);
|
|
ctx.SaveChanges();
|
|
}
|
|
}
|
|
|
|
// server has never been added before
|
|
if (server == null)
|
|
{
|
|
server = new EFServer()
|
|
{
|
|
Port = sv.Port,
|
|
EndPoint = sv.ToString(),
|
|
ServerId = serverId,
|
|
GameName = (Reference.Game?)sv.GameName,
|
|
HostName = sv.Hostname
|
|
};
|
|
|
|
server = serverSet.Add(server).Entity;
|
|
// this doesn't need to be async as it's during initialization
|
|
ctx.SaveChanges();
|
|
}
|
|
|
|
// we want to set the gamename up if it's never been set, or it changed
|
|
else if (!server.GameName.HasValue || server.GameName.Value != (Reference.Game)sv.GameName)
|
|
{
|
|
server.GameName = (Reference.Game)sv.GameName;
|
|
ctx.Entry(server).Property(_prop => _prop.GameName).IsModified = true;
|
|
ctx.SaveChanges();
|
|
}
|
|
|
|
if (server.HostName == null || server.HostName != sv.Hostname)
|
|
{
|
|
server.HostName = sv.Hostname;
|
|
ctx.Entry(server).Property(_prop => _prop.HostName).IsModified = true;
|
|
ctx.SaveChanges();
|
|
}
|
|
|
|
ctx.Entry(server).Property(_prop => _prop.IsPasswordProtected).IsModified = true;
|
|
server.IsPasswordProtected = !string.IsNullOrEmpty(sv.GamePassword);
|
|
ctx.SaveChanges();
|
|
|
|
// check to see if the stats have ever been initialized
|
|
var serverStats = InitializeServerStats(server.ServerId);
|
|
|
|
_servers.TryAdd(serverId, new ServerStats(server, serverStats, sv)
|
|
{
|
|
IsTeamBased = sv.Gametype != "dm"
|
|
});
|
|
}
|
|
|
|
catch (Exception e)
|
|
{
|
|
_log.LogError(e, "{message}",
|
|
Utilities.CurrentLocalization.LocalizationIndex["PLUGIN_STATS_ERROR_ADD"]);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Add Player to the player stats
|
|
/// </summary>
|
|
/// <param name="pl">Player to add/retrieve stats for</param>
|
|
/// <returns>EFClientStatistic of specified player</returns>
|
|
public async Task<EFClientStatistics> AddPlayer(EFClient pl)
|
|
{
|
|
var existingStats = pl.GetAdditionalProperty<EFClientStatistics>(CLIENT_STATS_KEY);
|
|
|
|
if (existingStats != null)
|
|
{
|
|
return existingStats;
|
|
}
|
|
|
|
try
|
|
{
|
|
await _addPlayerWaiter.WaitAsync();
|
|
long serverId = GetIdForServer(pl.CurrentServer);
|
|
|
|
if (!_servers.ContainsKey(serverId))
|
|
{
|
|
_log.LogError("[Stats::AddPlayer] Server with id {serverId} could not be found", serverId);
|
|
return null;
|
|
}
|
|
|
|
if (pl.ClientId <= 0)
|
|
{
|
|
_log.LogWarning("Stats for {Client} are not yet initialized", pl.ToString());
|
|
return null;
|
|
}
|
|
|
|
// get the client's stats from the database if it exists, otherwise create and attach a new one
|
|
// if this fails we want to throw an exception
|
|
|
|
EFClientStatistics clientStats;
|
|
|
|
await using var ctx = _contextFactory.CreateContext(enableTracking: false);
|
|
var clientStatsSet = ctx.Set<EFClientStatistics>();
|
|
clientStats = clientStatsSet
|
|
.Include(cl => cl.HitLocations)
|
|
.FirstOrDefault(c => c.ClientId == pl.ClientId && c.ServerId == serverId);
|
|
|
|
if (clientStats == null)
|
|
{
|
|
clientStats = new EFClientStatistics()
|
|
{
|
|
Active = true,
|
|
ClientId = pl.ClientId,
|
|
Deaths = 0,
|
|
Kills = 0,
|
|
ServerId = serverId,
|
|
Skill = 0.0,
|
|
SPM = 0.0,
|
|
EloRating = 200.0,
|
|
HitLocations = Enum.GetValues(typeof(IW4Info.HitLocation)).OfType<IW4Info.HitLocation>()
|
|
.Select(hl => new EFHitLocationCount()
|
|
{
|
|
Active = true,
|
|
HitCount = 0,
|
|
Location = (int)hl
|
|
}).ToList()
|
|
};
|
|
|
|
// insert if they've not been added
|
|
clientStats = clientStatsSet.Add(clientStats).Entity;
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
pl.SetAdditionalProperty(CLIENT_STATS_KEY, clientStats);
|
|
|
|
// migration for previous existing stats
|
|
if (clientStats.HitLocations.Count == 0)
|
|
{
|
|
clientStats.HitLocations = Enum.GetValues(typeof(IW4Info.HitLocation))
|
|
.OfType<IW4Info.HitLocation>()
|
|
.Select(hl => new EFHitLocationCount()
|
|
{
|
|
Active = true,
|
|
HitCount = 0,
|
|
Location = (int)hl
|
|
})
|
|
.ToList();
|
|
|
|
ctx.Update(clientStats);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
// for stats before rating
|
|
if (clientStats.EloRating == 0.0)
|
|
{
|
|
clientStats.EloRating = clientStats.Skill;
|
|
}
|
|
|
|
if (clientStats.RollingWeightedKDR == 0)
|
|
{
|
|
clientStats.RollingWeightedKDR = clientStats.KDR;
|
|
}
|
|
|
|
// set these on connecting
|
|
clientStats.LastActive = DateTime.UtcNow;
|
|
clientStats.LastStatCalculation = DateTime.UtcNow;
|
|
clientStats.SessionScore = pl.Score;
|
|
clientStats.LastScore = pl.Score;
|
|
|
|
pl.SetAdditionalProperty(CLIENT_DETECTIONS_KEY, new Detection(_log, clientStats));
|
|
_log.LogDebug("Added {client} to stats", pl.ToString());
|
|
|
|
return clientStats;
|
|
}
|
|
|
|
catch (DbUpdateException updateException) when (
|
|
updateException.InnerException is PostgresException { SqlState: "23503" }
|
|
|| updateException.InnerException is SqliteException { SqliteErrorCode: 787 }
|
|
|| updateException.InnerException is MySqlException { SqlState: "23503" })
|
|
{
|
|
_log.LogWarning("Trying to add {Client} to stats before they have been added to the database",
|
|
pl.ToString());
|
|
}
|
|
|
|
catch (Exception ex)
|
|
{
|
|
_log.LogError(ex, "Could not add client to stats {@client}", pl.ToString());
|
|
}
|
|
|
|
finally
|
|
{
|
|
if (_addPlayerWaiter.CurrentCount == 0)
|
|
{
|
|
_addPlayerWaiter.Release(1);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Perform stat updates for disconnecting client
|
|
/// </summary>
|
|
/// <param name="pl">Disconnecting client</param>
|
|
/// <returns></returns>
|
|
public async Task RemovePlayer(EFClient pl)
|
|
{
|
|
_log.LogDebug("Removing {client} from stats", pl.ToString());
|
|
|
|
if (pl.CurrentServer == null)
|
|
{
|
|
_log.LogWarning("Disconnecting client {client} is not on a server", pl.ToString());
|
|
return;
|
|
}
|
|
|
|
var serverId = GetIdForServer(pl.CurrentServer);
|
|
var serverStats = _servers[serverId].ServerStatistics;
|
|
|
|
// get individual client's stats
|
|
var clientStats = pl.GetAdditionalProperty<EFClientStatistics>(CLIENT_STATS_KEY);
|
|
// sync their stats before they leave
|
|
if (clientStats != null)
|
|
{
|
|
clientStats = UpdateStats(clientStats, pl);
|
|
await SaveClientStats(clientStats);
|
|
if (_config.EnableAdvancedMetrics)
|
|
{
|
|
await UpdateHistoricalRanking(pl.ClientId, clientStats, serverId);
|
|
}
|
|
|
|
// increment the total play time
|
|
serverStats.TotalPlayTime += pl.ConnectionLength;
|
|
pl.SetAdditionalProperty(CLIENT_STATS_KEY, null);
|
|
}
|
|
|
|
else
|
|
{
|
|
_log.LogWarning("Disconnecting client {client} has not been added to stats", pl.ToString());
|
|
}
|
|
}
|
|
|
|
private async Task SaveClientStats(EFClientStatistics clientStats)
|
|
{
|
|
await using var ctx = _contextFactory.CreateContext();
|
|
ctx.Update(clientStats);
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
public void AddDamageEvent(string eventLine, int attackerClientId, int victimClientId, long serverId)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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,
|
|
string fraction, string visibilityPercentage, string snapAngles, string isAlive, string lastAttackTime)
|
|
{
|
|
Vector3 vDeathOrigin = null;
|
|
Vector3 vKillOrigin = null;
|
|
Vector3 vViewAngles = null;
|
|
var snapshotAngles = new List<Vector3>();
|
|
SemaphoreSlim waiter = null;
|
|
|
|
try
|
|
{
|
|
try
|
|
{
|
|
vDeathOrigin = Vector3.Parse(deathOrigin);
|
|
vKillOrigin = Vector3.Parse(killOrigin);
|
|
vViewAngles = Vector3.Parse(viewAngles).FixIW4Angles();
|
|
|
|
foreach (string angle in snapAngles.Split(':', StringSplitOptions.RemoveEmptyEntries))
|
|
{
|
|
snapshotAngles.Add(Vector3.Parse(angle).FixIW4Angles());
|
|
}
|
|
}
|
|
|
|
catch (FormatException ex)
|
|
{
|
|
_log.LogWarning(ex, "Could not parse vector data from hit");
|
|
return;
|
|
}
|
|
|
|
EFClientKill hit;
|
|
try
|
|
{
|
|
hit = new EFClientKill
|
|
{
|
|
Active = true,
|
|
AttackerId = attacker.ClientId,
|
|
VictimId = victim.ClientId,
|
|
ServerId = serverId,
|
|
DeathOrigin = vDeathOrigin,
|
|
KillOrigin = vKillOrigin,
|
|
DeathType = (int)ParseEnum<IW4Info.MeansOfDeath>.Get(type, typeof(IW4Info.MeansOfDeath)),
|
|
Damage = int.Parse(damage),
|
|
HitLoc = (int)ParseEnum<IW4Info.HitLocation>.Get(hitLoc, typeof(IW4Info.HitLocation)),
|
|
WeaponReference = weapon,
|
|
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),
|
|
IsKill = !isDamage,
|
|
AnglesList = snapshotAngles,
|
|
IsAlive = isAlive == "1",
|
|
TimeSinceLastAttack = long.Parse(lastAttackTime),
|
|
GameName = (int)attacker.CurrentServer.GameName
|
|
};
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_log.LogError(ex,
|
|
"Could not parse script hit data. Damage={Damage}, TimeOffset={Offset}, TimeSinceLastAttack={LastAttackTime}",
|
|
damage, offset, lastAttackTime);
|
|
|
|
return;
|
|
}
|
|
|
|
hit.SetAdditionalProperty("HitLocationReference", hitLoc);
|
|
|
|
if (hit.HitLoc == (int)IW4Info.HitLocation.shield)
|
|
{
|
|
// we don't care about shield hits
|
|
return;
|
|
}
|
|
|
|
var clientDetection = attacker.GetAdditionalProperty<Detection>(CLIENT_DETECTIONS_KEY);
|
|
var clientStats = attacker.GetAdditionalProperty<EFClientStatistics>(CLIENT_STATS_KEY);
|
|
|
|
if (clientDetection == null || clientStats?.ClientId == null)
|
|
{
|
|
_log.LogWarning("Client stats state for {Client} is not yet initialized", attacker.ToString());
|
|
return;
|
|
}
|
|
|
|
waiter = clientStats.ProcessingHit;
|
|
await waiter.WaitAsync(Utilities.DefaultCommandTimeout, Plugin.ServerManager.CancellationToken);
|
|
|
|
// increment their hit count
|
|
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;
|
|
}
|
|
|
|
if (hit.IsKillstreakKill)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (Plugin.Config.Configuration().StoreClientKills)
|
|
{
|
|
var serverWaiter = _servers[serverId].OnSaving;
|
|
try
|
|
{
|
|
await serverWaiter.WaitAsync();
|
|
var cache = _servers[serverId].HitCache;
|
|
cache.Add(hit);
|
|
|
|
if (cache.Count > MAX_CACHED_HITS)
|
|
{
|
|
await SaveHitCache(serverId);
|
|
}
|
|
}
|
|
|
|
catch (Exception e)
|
|
{
|
|
_log.LogError(e, "Could not store client kills");
|
|
}
|
|
|
|
finally
|
|
{
|
|
if (serverWaiter.CurrentCount == 0)
|
|
{
|
|
serverWaiter.Release(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Plugin.Config.Configuration().AnticheatConfiguration.Enable && !attacker.IsBot &&
|
|
attacker.ClientId != victim.ClientId)
|
|
{
|
|
clientDetection.TrackedHits.Add(hit);
|
|
|
|
if (clientDetection.TrackedHits.Count >= MIN_HITS_TO_RUN_DETECTION)
|
|
{
|
|
while (clientDetection.TrackedHits.Count > 0)
|
|
{
|
|
var oldestHit = clientDetection.TrackedHits
|
|
.OrderBy(_hits => _hits.TimeOffset)
|
|
.First();
|
|
|
|
clientDetection.TrackedHits.Remove(oldestHit);
|
|
|
|
if (oldestHit.IsAlive)
|
|
{
|
|
var result = DeterminePenaltyResult(clientDetection.ProcessHit(oldestHit), attacker);
|
|
|
|
if (!Utilities.IsDevelopment)
|
|
{
|
|
await ApplyPenalty(result, attacker);
|
|
}
|
|
|
|
if (clientDetection.Tracker.HasChanges &&
|
|
result.ClientPenalty != EFPenalty.PenaltyType.Any)
|
|
{
|
|
await SaveTrackedSnapshots(clientDetection);
|
|
|
|
if (result.ClientPenalty == EFPenalty.PenaltyType.Ban)
|
|
{
|
|
// we don't care about any additional hits now that they're banned
|
|
clientDetection.TrackedHits.Clear();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
catch (TaskCanceledException)
|
|
{
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_log.LogError(ex, "Could not save hit or anti-cheat info {Attacker} {Victim} {Server}", attacker,
|
|
victim, serverId);
|
|
}
|
|
|
|
finally
|
|
{
|
|
if (waiter?.CurrentCount == 0)
|
|
{
|
|
waiter.Release();
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
public async Task SaveHitCache(long serverId)
|
|
{
|
|
await using var ctx = _contextFactory.CreateContext(enableTracking: false);
|
|
var server = _servers[serverId];
|
|
ctx.AddRange(server.HitCache.ToList());
|
|
await ctx.SaveChangesAsync();
|
|
server.HitCache.Clear();
|
|
}
|
|
|
|
private bool ShouldUseDetection(Server server, DetectionType detectionType, long clientId)
|
|
{
|
|
var serverDetectionTypes = Plugin.Config.Configuration().AnticheatConfiguration.ServerDetectionTypes;
|
|
var gameDetectionTypes = Plugin.Config.Configuration().AnticheatConfiguration.GameDetectionTypes;
|
|
var ignoredClients = Plugin.Config.Configuration().AnticheatConfiguration.IgnoredClientIds;
|
|
|
|
if (ignoredClients.Contains(clientId))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
if (!serverDetectionTypes[server.EndPoint].Contains(detectionType))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
catch (KeyNotFoundException)
|
|
{
|
|
}
|
|
|
|
try
|
|
{
|
|
if (!gameDetectionTypes[server.GameName].Contains(detectionType))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// ignored
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
async Task ApplyPenalty(DetectionPenaltyResult penalty, EFClient attacker)
|
|
{
|
|
var penaltyClient = Utilities.IW4MAdminClient(attacker.CurrentServer);
|
|
switch (penalty.ClientPenalty)
|
|
{
|
|
case EFPenalty.PenaltyType.Ban:
|
|
if (attacker.Level == EFClient.Permission.Banned)
|
|
{
|
|
break;
|
|
}
|
|
|
|
penaltyClient.AdministeredPenalties = new List<EFPenalty>()
|
|
{
|
|
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}",
|
|
}
|
|
};
|
|
|
|
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)
|
|
{
|
|
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}";
|
|
|
|
penaltyClient.AdministeredPenalties = new List<EFPenalty>()
|
|
{
|
|
new EFPenalty()
|
|
{
|
|
AutomatedOffense = flagReason
|
|
}
|
|
};
|
|
|
|
await attacker.Flag(flagReason, penaltyClient, new TimeSpan(168, 0, 0))
|
|
.WaitAsync(Utilities.DefaultCommandTimeout, attacker.CurrentServer.Manager.CancellationToken);
|
|
break;
|
|
}
|
|
}
|
|
|
|
async Task SaveTrackedSnapshots(Detection clientDetection)
|
|
{
|
|
EFACSnapshot change;
|
|
|
|
await using var ctx = _contextFactory.CreateContext();
|
|
while ((change = clientDetection.Tracker.GetNextChange()) != default(EFACSnapshot))
|
|
{
|
|
ctx.Add(change);
|
|
}
|
|
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task AddStandardKill(EFClient attacker, EFClient victim)
|
|
{
|
|
var serverId = GetIdForServer(attacker.CurrentServer);
|
|
|
|
var attackerStats = attacker.GetAdditionalProperty<EFClientStatistics>(CLIENT_STATS_KEY);
|
|
var victimStats = victim.GetAdditionalProperty<EFClientStatistics>(CLIENT_STATS_KEY);
|
|
|
|
// update the total stats
|
|
_servers[serverId].ServerStatistics.TotalKills += 1;
|
|
|
|
if (attackerStats == null)
|
|
{
|
|
_log.LogWarning("Stats for {Client} are not yet initialized", attacker.ToString());
|
|
return;
|
|
}
|
|
|
|
if (victimStats == null)
|
|
{
|
|
_log.LogWarning("Stats for {Client} are not yet initialized", victim.ToString());
|
|
return;
|
|
}
|
|
|
|
// this happens when the round has changed
|
|
if (attackerStats.SessionScore == 0)
|
|
{
|
|
attackerStats.LastScore = 0;
|
|
}
|
|
|
|
if (victimStats.SessionScore == 0)
|
|
{
|
|
victimStats.LastScore = 0;
|
|
}
|
|
|
|
var estimatedAttackerScore = attacker.CurrentServer.GameName != Server.Game.CSGO
|
|
? attacker.Score
|
|
: attackerStats.SessionKills * 50;
|
|
var estimatedVictimScore = attacker.CurrentServer.GameName != Server.Game.CSGO
|
|
? victim.Score
|
|
: victimStats.SessionKills * 50;
|
|
|
|
attackerStats.SessionScore = estimatedAttackerScore;
|
|
victimStats.SessionScore = estimatedVictimScore;
|
|
|
|
attacker.SetAdditionalProperty(ESTIMATED_SCORE, estimatedAttackerScore);
|
|
victim.SetAdditionalProperty(ESTIMATED_SCORE, estimatedVictimScore);
|
|
|
|
// calculate for the clients
|
|
CalculateKill(attackerStats, victimStats, attacker, victim);
|
|
// this should fix the negative SPM
|
|
// updates their last score after being calculated
|
|
attackerStats.LastScore = estimatedAttackerScore;
|
|
victimStats.LastScore = estimatedVictimScore;
|
|
|
|
// show encouragement/discouragement
|
|
var streakMessage = (attackerStats.ClientId != victimStats.ClientId)
|
|
? StreakMessage.MessageOnStreak(attackerStats.KillStreak, attackerStats.DeathStreak)
|
|
: StreakMessage.MessageOnStreak(-1, -1);
|
|
|
|
if (streakMessage != string.Empty)
|
|
{
|
|
attacker.Tell(streakMessage);
|
|
}
|
|
|
|
// fixme: why?
|
|
if (double.IsNaN(victimStats.SPM) || double.IsNaN(victimStats.Skill))
|
|
{
|
|
_log.LogWarning("victim SPM/SKILL {@victimStats}", victimStats);
|
|
victimStats.SPM = 0.0;
|
|
victimStats.Skill = 0.0;
|
|
}
|
|
|
|
if (double.IsNaN(attackerStats.SPM) || double.IsNaN(attackerStats.Skill))
|
|
{
|
|
_log.LogWarning("attacker SPM/SKILL {@attackerStats}", attackerStats);
|
|
attackerStats.SPM = 0.0;
|
|
attackerStats.Skill = 0.0;
|
|
}
|
|
|
|
// update their performance
|
|
if ((DateTime.UtcNow - attackerStats.LastStatHistoryUpdate).TotalMinutes >=
|
|
(Utilities.IsDevelopment ? 0.5 : _config.EnableAdvancedMetrics ? 5.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);
|
|
if (_config.EnableAdvancedMetrics)
|
|
{
|
|
await UpdateHistoricalRanking(attacker.ClientId, attackerStats, serverId);
|
|
}
|
|
|
|
else
|
|
{
|
|
await UpdateStatHistory(attacker, attackerStats);
|
|
}
|
|
|
|
attackerStats.LastStatHistoryUpdate = DateTime.UtcNow;
|
|
}
|
|
|
|
catch (Exception e)
|
|
{
|
|
_log.LogWarning(e, "Could not update stat history for {attacker}", attacker.ToString());
|
|
}
|
|
|
|
finally
|
|
{
|
|
if (attackerStats.ProcessingHit.CurrentCount == 0)
|
|
{
|
|
attackerStats.ProcessingHit.Release(1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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;
|
|
|
|
// don't update their stat history if they haven't played long
|
|
if (currentSessionTime < 60)
|
|
{
|
|
return;
|
|
}
|
|
|
|
int currentServerTotalPlaytime = clientStats.TimePlayed + currentSessionTime;
|
|
|
|
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;
|
|
|
|
// get the client ratings
|
|
var clientHistory = await iqHistoryLink
|
|
.FirstOrDefaultAsync() ?? new EFClientRatingHistory()
|
|
{
|
|
Active = true,
|
|
ClientId = client.ClientId,
|
|
Ratings = new List<EFRating>()
|
|
};
|
|
|
|
// it's the first time they've played
|
|
if (clientHistory.RatingHistoryId == 0)
|
|
{
|
|
ctx.Add(clientHistory);
|
|
}
|
|
|
|
#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;
|
|
|
|
// limit max history per server to 40
|
|
if (clientHistory.Ratings.Count(r => r.ServerId == clientStats.ServerId) >= 40)
|
|
{
|
|
// select the oldest one
|
|
var ratingToRemove = clientHistory.Ratings
|
|
.Where(r => r.ServerId == clientStats.ServerId)
|
|
.OrderBy(r => r.When)
|
|
.First();
|
|
|
|
ctx.Remove(ratingToRemove);
|
|
}
|
|
|
|
// set the previous newest to false
|
|
var ratingToUnsetNewest = clientHistory.Ratings
|
|
.Where(r => r.ServerId == clientStats.ServerId)
|
|
.OrderByDescending(r => r.When)
|
|
.FirstOrDefault();
|
|
|
|
if (ratingToUnsetNewest != null)
|
|
{
|
|
if (ratingToUnsetNewest.Newest)
|
|
{
|
|
ctx.Update(ratingToUnsetNewest);
|
|
ctx.Entry(ratingToUnsetNewest).Property(r => r.Newest).IsModified = true;
|
|
ratingToUnsetNewest.Newest = false;
|
|
}
|
|
}
|
|
|
|
var newServerRating = new EFRating()
|
|
{
|
|
Performance = clientStats.Performance,
|
|
Ranking = individualClientRanking,
|
|
Active = true,
|
|
Newest = true,
|
|
ServerId = clientStats.ServerId,
|
|
RatingHistory = clientHistory,
|
|
ActivityAmount = currentServerTotalPlaytime,
|
|
};
|
|
|
|
// add new rating for current server
|
|
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
|
|
};
|
|
|
|
var clientStatsList = await iqClientStats.ToListAsync();
|
|
|
|
// add the current server's so we don't have to pull it from the database
|
|
clientStatsList.Add(new
|
|
{
|
|
clientStats.Performance,
|
|
TimePlayed = currentServerTotalPlaytime
|
|
});
|
|
|
|
// weight the overall performance based on play time
|
|
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))
|
|
{
|
|
performanceAverage = clientStatsList.Average(p => p.Performance);
|
|
}
|
|
|
|
int overallClientRanking = await ctx.Set<EFRating>()
|
|
.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)
|
|
{
|
|
var ratingToRemove = clientHistory.Ratings
|
|
.Where(r => r.ServerId == null)
|
|
.OrderBy(r => r.When)
|
|
.First();
|
|
|
|
ctx.Remove(ratingToRemove);
|
|
}
|
|
|
|
// set the previous average newest to false
|
|
ratingToUnsetNewest = clientHistory.Ratings
|
|
.Where(r => r.ServerId == null)
|
|
.OrderByDescending(r => r.When)
|
|
.FirstOrDefault();
|
|
|
|
if (ratingToUnsetNewest != null)
|
|
{
|
|
if (ratingToUnsetNewest.Newest)
|
|
{
|
|
ctx.Update(ratingToUnsetNewest);
|
|
ctx.Entry(ratingToUnsetNewest).Property(r => r.Newest).IsModified = true;
|
|
ratingToUnsetNewest.Newest = false;
|
|
}
|
|
}
|
|
|
|
// add new average rating
|
|
var averageRating = new EFRating()
|
|
{
|
|
Active = true,
|
|
Newest = true,
|
|
Performance = performanceAverage,
|
|
Ranking = overallClientRanking,
|
|
ServerId = null,
|
|
RatingHistory = clientHistory,
|
|
ActivityAmount = clientStatsList.Sum(s => s.TimePlayed)
|
|
};
|
|
|
|
ctx.Add(averageRating);
|
|
|
|
#endregion
|
|
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task UpdateHistoricalRanking(int clientId, EFClientStatistics clientStats, long serverId)
|
|
{
|
|
await using var context = _contextFactory.CreateContext();
|
|
var minPlayTime = _config.TopPlayersMinPlayTime;
|
|
|
|
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 >= minPlayTime)
|
|
.ToListAsync();
|
|
|
|
if (clientStats.TimePlayed >= minPlayTime)
|
|
{
|
|
clientStats.ZScore = await _serverDistributionCalculator.GetZScoreForServer(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);
|
|
}
|
|
|
|
if (performances.Any(performance => performance.TimePlayed >= minPlayTime))
|
|
{
|
|
var aggregateZScore =
|
|
performances.WeightValueByPlaytime(nameof(EFClientStatistics.ZScore), minPlayTime);
|
|
|
|
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)
|
|
{
|
|
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);
|
|
}
|
|
|
|
const int maxRankingCount = 1728; // 60 / 2.5 * 24 * 3 ( 3 days at sample every 2.5 minutes)
|
|
|
|
if (totalRankingEntries > maxRankingCount)
|
|
{
|
|
var lastRating = await context.Set<EFClientRankingHistory>()
|
|
.Where(r => r.ClientId == clientId)
|
|
.Where(r => r.ServerId == serverId)
|
|
.OrderBy(r => r.CreatedDateTime)
|
|
.FirstOrDefaultAsync();
|
|
|
|
if (lastRating is not null)
|
|
{
|
|
context.Remove(lastRating);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Performs the incrementation of kills and deaths for client statistics
|
|
/// </summary>
|
|
/// <param name="attackerStats">Stats of the attacker</param>
|
|
/// <param name="victimStats">Stats of the victim</param>
|
|
public void CalculateKill(EFClientStatistics attackerStats, EFClientStatistics victimStats,
|
|
EFClient attacker, EFClient victim)
|
|
{
|
|
bool suicide = attackerStats.ClientId == victimStats.ClientId;
|
|
|
|
// only update their kills if they didn't kill themselves
|
|
if (!suicide)
|
|
{
|
|
attackerStats.Kills += 1;
|
|
attackerStats.MatchData.Kills += 1;
|
|
attackerStats.SessionKills += 1;
|
|
attackerStats.KillStreak += 1;
|
|
attackerStats.DeathStreak = 0;
|
|
}
|
|
|
|
victimStats.Deaths += 1;
|
|
victimStats.MatchData.Deaths += 1;
|
|
victimStats.SessionDeaths += 1;
|
|
victimStats.DeathStreak += 1;
|
|
victimStats.KillStreak = 0;
|
|
|
|
// process the attacker's stats after the kills
|
|
attackerStats = UpdateStats(attackerStats, attacker);
|
|
|
|
// calculate elo
|
|
var attackerEloDifference = Math.Log(Math.Max(1, victimStats.EloRating)) -
|
|
Math.Log(Math.Max(1, attackerStats.EloRating));
|
|
var winPercentage = 1.0 / (1 + Math.Pow(10, attackerEloDifference / Math.E));
|
|
|
|
attackerStats.EloRating += 6.0 * (1 - winPercentage);
|
|
victimStats.EloRating -= 6.0 * (1 - winPercentage);
|
|
|
|
attackerStats.EloRating = Math.Max(0, Math.Round(attackerStats.EloRating, 2));
|
|
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.LastActive = DateTime.UtcNow;
|
|
victimStats.LastActive = DateTime.UtcNow;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Update the client stats (skill etc)
|
|
/// </summary>
|
|
/// <param name="clientStats">Client statistics</param>
|
|
/// <returns></returns>
|
|
private EFClientStatistics UpdateStats(EFClientStatistics clientStats, EFClient client)
|
|
{
|
|
// prevent NaN or inactive time lowering SPM
|
|
if ((DateTime.UtcNow - clientStats.LastStatCalculation).TotalSeconds / 60.0 < 0.01 ||
|
|
(DateTime.UtcNow - clientStats.LastActive).TotalSeconds / 60.0 > 3 ||
|
|
clientStats.SessionScore == 0)
|
|
{
|
|
// prevents idle time counting
|
|
clientStats.LastStatCalculation = DateTime.UtcNow;
|
|
return clientStats;
|
|
}
|
|
|
|
var timeSinceLastCalc = (DateTime.UtcNow - clientStats.LastStatCalculation).TotalSeconds / 60.0;
|
|
|
|
var scoreDifference = 0;
|
|
// this means they've been tking or suicide and is the only time they can have a negative SPM
|
|
if (clientStats.RoundScore < 0)
|
|
{
|
|
scoreDifference = clientStats.RoundScore + clientStats.LastScore;
|
|
}
|
|
|
|
else if (clientStats.RoundScore > 0 && clientStats.LastScore < clientStats.RoundScore)
|
|
{
|
|
scoreDifference = clientStats.RoundScore - clientStats.LastScore;
|
|
}
|
|
|
|
var killSpm = scoreDifference / timeSinceLastCalc;
|
|
var 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
|
|
clientStats.SessionSPM = clientStats.SessionScore / Math.Max(1, client.ConnectionLength / 60.0);
|
|
|
|
// 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 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;
|
|
|
|
double SPMAgainstPlayWeight = timeSinceLastCalc / Math.Min(600, (totalPlayTime / 60.0));
|
|
|
|
// calculate the new weight against average times the weight against play time
|
|
clientStats.SPM = (killSpm * SPMAgainstPlayWeight) + (clientStats.SPM * (1 - SPMAgainstPlayWeight));
|
|
|
|
if (clientStats.SPM < 0)
|
|
{
|
|
_log.LogWarning("clientStats SPM < 0 {scoreDifference} {@clientStats}", scoreDifference, clientStats);
|
|
clientStats.SPM = 0;
|
|
}
|
|
|
|
clientStats.SPM = Math.Round(clientStats.SPM, 3);
|
|
clientStats.Skill = Math.Round((clientStats.SPM * KDRWeight), 3);
|
|
|
|
// fixme: how does this happen?
|
|
if (double.IsNaN(clientStats.SPM) || double.IsNaN(clientStats.Skill))
|
|
{
|
|
_log.LogWarning("clientStats SPM/Skill NaN {@killInfo}",
|
|
new
|
|
{
|
|
killSPM = 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;
|
|
}
|
|
|
|
public EFServerStatistics InitializeServerStats(long serverId)
|
|
{
|
|
EFServerStatistics serverStats;
|
|
|
|
using var ctx = _contextFactory.CreateContext(enableTracking: false);
|
|
var serverStatsSet = ctx.Set<EFServerStatistics>();
|
|
serverStats = serverStatsSet.FirstOrDefault(s => s.ServerId == serverId);
|
|
|
|
if (serverStats == null)
|
|
{
|
|
_log.LogDebug("Initializing server stats for {serverId}", serverId);
|
|
// server stats have never been generated before
|
|
serverStats = new EFServerStatistics()
|
|
{
|
|
ServerId = serverId,
|
|
TotalKills = 0,
|
|
TotalPlayTime = 0,
|
|
};
|
|
|
|
serverStats = serverStatsSet.Add(serverStats).Entity;
|
|
ctx.SaveChanges();
|
|
}
|
|
|
|
return serverStats;
|
|
}
|
|
|
|
public void ResetKillstreaks(Server sv)
|
|
{
|
|
foreach (var session in sv.GetClientsAsList()
|
|
.Select(_client => new
|
|
{
|
|
stat = _client.GetAdditionalProperty<EFClientStatistics>(CLIENT_STATS_KEY),
|
|
detection = _client.GetAdditionalProperty<Detection>(CLIENT_DETECTIONS_KEY)
|
|
}))
|
|
{
|
|
session.stat?.StartNewSession();
|
|
session.detection?.OnMapChange();
|
|
session.stat?.MatchData?.StartNewMatch();
|
|
}
|
|
}
|
|
|
|
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;
|
|
stats.Skill = 0;
|
|
stats.TimePlayed = 0;
|
|
stats.EloRating = 200;
|
|
}
|
|
|
|
public async Task AddMessageAsync(int clientId, long serverId, bool sentIngame, string message)
|
|
{
|
|
// the web users can have no account
|
|
if (clientId < 1)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await using var ctx = _contextFactory.CreateContext(enableTracking: false);
|
|
ctx.Set<EFClientMessage>().Add(new EFClientMessage()
|
|
{
|
|
ClientId = clientId,
|
|
Message = message,
|
|
ServerId = serverId,
|
|
TimeSent = DateTime.UtcNow,
|
|
SentIngame = sentIngame
|
|
});
|
|
|
|
await ctx.SaveChangesAsync();
|
|
}
|
|
|
|
public async Task Sync(Server sv)
|
|
{
|
|
long serverId = GetIdForServer(sv);
|
|
|
|
var waiter = _servers[serverId].OnSaving;
|
|
try
|
|
{
|
|
await waiter.WaitAsync();
|
|
|
|
await using var ctx = _contextFactory.CreateContext();
|
|
var serverStatsSet = ctx.Set<EFServerStatistics>();
|
|
serverStatsSet.Update(_servers[serverId].ServerStatistics);
|
|
await ctx.SaveChangesAsync();
|
|
|
|
foreach (var stats in sv.GetClientsAsList()
|
|
.Select(_client => _client.GetAdditionalProperty<EFClientStatistics>(CLIENT_STATS_KEY))
|
|
.Where(_stats => _stats != null))
|
|
{
|
|
await SaveClientStats(stats);
|
|
}
|
|
|
|
await SaveHitCache(serverId);
|
|
}
|
|
|
|
catch (Exception e)
|
|
{
|
|
_log.LogError(e, "There was a problem syncing server stats");
|
|
}
|
|
|
|
finally
|
|
{
|
|
if (waiter.CurrentCount == 0)
|
|
{
|
|
waiter.Release(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
public void SetTeamBased(long serverId, bool isTeamBased)
|
|
{
|
|
_servers[serverId].IsTeamBased = isTeamBased;
|
|
}
|
|
|
|
public static long GetIdForServer(Server server)
|
|
{
|
|
if ($"{server.IP}:{server.Port.ToString()}" == "66.150.121.184:28965")
|
|
{
|
|
return 886229536;
|
|
}
|
|
|
|
// todo: this is not stable and will need to be migrated again...
|
|
long id = HashCode.Combine(server.IP, server.Port);
|
|
id = id < 0 ? Math.Abs(id) : id;
|
|
long? serverId;
|
|
|
|
serverId = serverModels.FirstOrDefault(_server => _server.ServerId == server.EndPoint ||
|
|
_server.EndPoint == server.ToString() ||
|
|
_server.ServerId == id)?.ServerId;
|
|
|
|
if (!serverId.HasValue)
|
|
{
|
|
return id;
|
|
}
|
|
|
|
return serverId.Value;
|
|
}
|
|
}
|
|
}
|