diff --git a/Plugins/Stats/Cheat/Detection.cs b/Plugins/Stats/Cheat/Detection.cs index bcd8737df..a96058f46 100644 --- a/Plugins/Stats/Cheat/Detection.cs +++ b/Plugins/Stats/Cheat/Detection.cs @@ -1,6 +1,5 @@ using SharedLibraryCore.Database.Models; using SharedLibraryCore.Helpers; -using SharedLibraryCore.Interfaces; using System; using System.Collections.Generic; using System.Linq; @@ -10,6 +9,7 @@ using Data.Models.Client; using Data.Models.Client.Stats; using Microsoft.Extensions.Logging; using SharedLibraryCore; +using Stats.Config; using ILogger = Microsoft.Extensions.Logging.ILogger; namespace IW4MAdmin.Plugins.Stats.Cheat @@ -37,6 +37,7 @@ namespace IW4MAdmin.Plugins.Stats.Cheat Dictionary HitLocationCount; double AngleDifferenceAverage; EFClientStatistics ClientStats; + private readonly StatsConfiguration _statsConfiguration; long LastOffset; string LastWeapon; ILogger Log; @@ -55,7 +56,7 @@ namespace IW4MAdmin.Plugins.Stats.Cheat public double Offset { get; set; } }; - public Detection(ILogger log, EFClientStatistics clientStats) + public Detection(ILogger log, EFClientStatistics clientStats, StatsConfiguration statsConfiguration) { Log = log; HitLocationCount = new Dictionary(); @@ -65,6 +66,7 @@ namespace IW4MAdmin.Plugins.Stats.Cheat } ClientStats = clientStats; + _statsConfiguration = statsConfiguration; Strain = new Strain(); Tracker = new ChangeTracking(); TrackedHits = new List(); @@ -308,7 +310,7 @@ namespace IW4MAdmin.Plugins.Stats.Cheat bool shouldIgnoreDetection = false; try { - shouldIgnoreDetection = Plugin.Config.Configuration().AnticheatConfiguration.IgnoredDetectionSpecification[(Server.Game)hit.GameName][DetectionType.Recoil] + shouldIgnoreDetection = _statsConfiguration.AnticheatConfiguration.IgnoredDetectionSpecification[(Server.Game)hit.GameName][DetectionType.Recoil] .Any(_weaponRegex => Regex.IsMatch(hit.WeaponReference, _weaponRegex)); } @@ -340,7 +342,7 @@ namespace IW4MAdmin.Plugins.Stats.Cheat try { shouldIgnoreDetection = false; - shouldIgnoreDetection = Plugin.Config.Configuration().AnticheatConfiguration.IgnoredDetectionSpecification[(Server.Game)hit.GameName][DetectionType.Button] + shouldIgnoreDetection = _statsConfiguration.AnticheatConfiguration.IgnoredDetectionSpecification[(Server.Game)hit.GameName][DetectionType.Button] .Any(_weaponRegex => Regex.IsMatch(hit.WeaponReference, _weaponRegex)); } @@ -453,7 +455,7 @@ namespace IW4MAdmin.Plugins.Stats.Cheat try { shouldIgnoreDetection = false; // reset previous value - shouldIgnoreDetection = Plugin.Config.Configuration().AnticheatConfiguration.IgnoredDetectionSpecification[(Server.Game)hit.GameName][DetectionType.Chest] + shouldIgnoreDetection = _statsConfiguration.AnticheatConfiguration.IgnoredDetectionSpecification[(Server.Game)hit.GameName][DetectionType.Chest] .Any(_weaponRegex => Regex.IsMatch(hit.WeaponReference, _weaponRegex)); } diff --git a/Plugins/Stats/Client/Abstractions/IClientStatisticCalculator.cs b/Plugins/Stats/Client/Abstractions/IClientStatisticCalculator.cs index 872278b99..388e54f75 100644 --- a/Plugins/Stats/Client/Abstractions/IClientStatisticCalculator.cs +++ b/Plugins/Stats/Client/Abstractions/IClientStatisticCalculator.cs @@ -1,11 +1,11 @@ using System.Threading.Tasks; -using SharedLibraryCore; +using SharedLibraryCore.Events; namespace IW4MAdmin.Plugins.Stats.Client.Abstractions { public interface IClientStatisticCalculator { Task GatherDependencies(); - Task CalculateForEvent(GameEvent gameEvent); + Task CalculateForEvent(CoreEvent coreEvent); } -} \ No newline at end of file +} diff --git a/Plugins/Stats/Client/Abstractions/IHitInfoBuilder.cs b/Plugins/Stats/Client/Abstractions/IHitInfoBuilder.cs index 2733eacc2..ca108b7c2 100644 --- a/Plugins/Stats/Client/Abstractions/IHitInfoBuilder.cs +++ b/Plugins/Stats/Client/Abstractions/IHitInfoBuilder.cs @@ -1,11 +1,11 @@ -using IW4MAdmin.Plugins.Stats.Client.Game; -using SharedLibraryCore; +using Data.Models; +using IW4MAdmin.Plugins.Stats.Client.Game; using SharedLibraryCore.Interfaces; namespace Stats.Client.Abstractions { public interface IHitInfoBuilder { - HitInfo Build(string[] log, ParserRegex parserRegex, int entityId, bool isSelf, bool isVictim, Server.Game gameName); + HitInfo Build(string[] log, ParserRegex parserRegex, int entityId, bool isSelf, bool isVictim, Reference.Game gameName); } -} \ No newline at end of file +} diff --git a/Plugins/Stats/Client/Abstractions/IWeaponNameParser.cs b/Plugins/Stats/Client/Abstractions/IWeaponNameParser.cs index 17367af10..576829fec 100644 --- a/Plugins/Stats/Client/Abstractions/IWeaponNameParser.cs +++ b/Plugins/Stats/Client/Abstractions/IWeaponNameParser.cs @@ -1,10 +1,9 @@ -using SharedLibraryCore; +using Data.Models; using Stats.Client.Game; -namespace Stats.Client.Abstractions +namespace Stats.Client.Abstractions; + +public interface IWeaponNameParser { - public interface IWeaponNameParser - { - WeaponInfo Parse(string weaponName, Server.Game gameName); - } + WeaponInfo Parse(string weaponName, Reference.Game gameName); } diff --git a/Plugins/Stats/Client/HitCalculator.cs b/Plugins/Stats/Client/HitCalculator.cs index 276c8e285..e0bcb137d 100644 --- a/Plugins/Stats/Client/HitCalculator.cs +++ b/Plugins/Stats/Client/HitCalculator.cs @@ -14,413 +14,125 @@ using IW4MAdmin.Plugins.Stats.Client.Game; using IW4MAdmin.Plugins.Stats.Helpers; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using SharedLibraryCore; using SharedLibraryCore.Database.Models; +using SharedLibraryCore.Events; +using SharedLibraryCore.Events.Game; +using SharedLibraryCore.Events.Management; using Stats.Client.Abstractions; using Stats.Client.Game; -namespace IW4MAdmin.Plugins.Stats.Client +namespace IW4MAdmin.Plugins.Stats.Client; + +public class HitState { - public class HitState + public HitState() { - public HitState() - { - OnTransaction = new SemaphoreSlim(1, 1); - } - - ~HitState() - { - OnTransaction.Dispose(); - } - - public List Hits { get; set; } - public DateTime? LastUsage { get; set; } - public int? LastWeaponId { get; set; } - public EFServer Server { get; set; } - public SemaphoreSlim OnTransaction { get; } - public int UpdateCount { get; set; } + OnTransaction = new SemaphoreSlim(1, 1); } - public class HitCalculator : IClientStatisticCalculator + ~HitState() { - private readonly IDatabaseContextFactory _contextFactory; - private readonly ILogger _logger; + OnTransaction.Dispose(); + } - private readonly ConcurrentDictionary _clientHitStatistics = - new ConcurrentDictionary(); + public List Hits { get; set; } + public DateTime? LastUsage { get; set; } + public int? LastWeaponId { get; set; } + public EFServer Server { get; set; } + public SemaphoreSlim OnTransaction { get; } + public int UpdateCount { get; set; } +} - private readonly SemaphoreSlim _onTransaction = new SemaphoreSlim(1, 1); +public class HitCalculator : IClientStatisticCalculator +{ + private readonly IDatabaseContextFactory _contextFactory; + private readonly ILogger _logger; - private readonly ILookupCache _serverCache; - private readonly ILookupCache _hitLocationCache; - private readonly ILookupCache _weaponCache; - private readonly ILookupCache _attachmentCache; - private readonly ILookupCache _attachmentComboCache; - private readonly ILookupCache _modCache; - private readonly IHitInfoBuilder _hitInfoBuilder; - private readonly IServerDistributionCalculator _serverDistributionCalculator; + private readonly ConcurrentDictionary _clientHitStatistics = new(); - private readonly TimeSpan _maxActiveTime = TimeSpan.FromMinutes(2); - private const int MaxUpdatesBeforePersist = 20; - private const string SessionScores = nameof(SessionScores); + private readonly SemaphoreSlim _onTransaction = new SemaphoreSlim(1, 1); - public HitCalculator(ILogger logger, IDatabaseContextFactory contextFactory, - ILookupCache hitLocationCache, ILookupCache weaponCache, - ILookupCache attachmentCache, - ILookupCache attachmentComboCache, - ILookupCache serverCache, ILookupCache modCache, IHitInfoBuilder hitInfoBuilder, - IServerDistributionCalculator serverDistributionCalculator) + private readonly ILookupCache _serverCache; + private readonly ILookupCache _hitLocationCache; + private readonly ILookupCache _weaponCache; + private readonly ILookupCache _attachmentCache; + private readonly ILookupCache _attachmentComboCache; + private readonly ILookupCache _modCache; + private readonly IHitInfoBuilder _hitInfoBuilder; + private readonly IServerDistributionCalculator _serverDistributionCalculator; + + private readonly TimeSpan _maxActiveTime = TimeSpan.FromMinutes(2); + private const int MaxUpdatesBeforePersist = 20; + private const string SessionScores = nameof(SessionScores); + + public HitCalculator(ILogger logger, IDatabaseContextFactory contextFactory, + ILookupCache hitLocationCache, ILookupCache weaponCache, + ILookupCache attachmentCache, + ILookupCache attachmentComboCache, + ILookupCache serverCache, ILookupCache modCache, IHitInfoBuilder hitInfoBuilder, + IServerDistributionCalculator serverDistributionCalculator) + { + _contextFactory = contextFactory; + _logger = logger; + _hitLocationCache = hitLocationCache; + _weaponCache = weaponCache; + _attachmentCache = attachmentCache; + _attachmentComboCache = attachmentComboCache; + _serverCache = serverCache; + _hitInfoBuilder = hitInfoBuilder; + _modCache = modCache; + _serverDistributionCalculator = serverDistributionCalculator; + } + + public async Task GatherDependencies() + { + await _hitLocationCache.InitializeAsync(); + await _weaponCache.InitializeAsync(); + await _attachmentCache.InitializeAsync(); + await _attachmentComboCache.InitializeAsync(); + await _serverCache.InitializeAsync(); + await _modCache.InitializeAsync(); + } + + public async Task CalculateForEvent(CoreEvent coreEvent) + { + if (coreEvent is ClientStateInitializeEvent clientStateInitializeEvent) { - _contextFactory = contextFactory; - _logger = logger; - _hitLocationCache = hitLocationCache; - _weaponCache = weaponCache; - _attachmentCache = attachmentCache; - _attachmentComboCache = attachmentComboCache; - _serverCache = serverCache; - _hitInfoBuilder = hitInfoBuilder; - _modCache = modCache; - _serverDistributionCalculator = serverDistributionCalculator; - } - - public async Task GatherDependencies() - { - await _hitLocationCache.InitializeAsync(); - await _weaponCache.InitializeAsync(); - await _attachmentCache.InitializeAsync(); - await _attachmentComboCache.InitializeAsync(); - await _serverCache.InitializeAsync(); - await _modCache.InitializeAsync(); - } - - public async Task CalculateForEvent(GameEvent gameEvent) - { - if (gameEvent.Type == GameEvent.EventType.Connect) + // if no servers have been cached yet we need to pull them here + // as they could have gotten added after we've initialized + if (!_serverCache.GetAll().Any()) { - // if no servers have been cached yet we need to pull them here - // as they could have gotten added after we've initialized - if (!_serverCache.GetAll().Any()) - { - await _serverCache.InitializeAsync(); - } + await _serverCache.InitializeAsync(); + } - gameEvent.Origin.SetAdditionalProperty(SessionScores, new List<(int, DateTime)>()); + clientStateInitializeEvent.Client.SetAdditionalProperty(SessionScores, new List<(int, DateTime)>()); + return; + } + + if (coreEvent is ClientStateDisposeEvent clientStateDisposeEvent) + { + _clientHitStatistics.Remove(clientStateDisposeEvent.Client.ClientId, out var state); + + if (state == null) + { + _logger.LogWarning("No client hit state available for disconnecting client {Client}", + clientStateDisposeEvent.Client.ToString()); return; } - if (gameEvent.Type == GameEvent.EventType.Disconnect) - { - _clientHitStatistics.Remove(gameEvent.Origin.ClientId, out var state); - - if (state == null) - { - _logger.LogWarning("No client hit state available for disconnecting client {client}", - gameEvent.Origin.ToString()); - return; - } - - try - { - await state.OnTransaction.WaitAsync(); - HandleDisconnectCalculations(gameEvent.Origin, state); - await UpdateClientStatistics(gameEvent.Origin.ClientId, state); - } - - catch (Exception ex) - { - _logger.LogError(ex, "Could not handle disconnect calculations for client {client}", - gameEvent.Origin.ToString()); - } - - finally - { - if (state.OnTransaction.CurrentCount == 0) - { - state.OnTransaction.Release(); - } - } - - return; - } - - if (gameEvent.Type == GameEvent.EventType.MapEnd) - { - foreach (var client in gameEvent.Owner.GetClientsAsList()) - { - var scores = client.GetAdditionalProperty>(SessionScores); - scores?.Add((client.GetAdditionalProperty(StatManager.ESTIMATED_SCORE) ?? client.Score, DateTime.Now)); - } - } - - if (gameEvent.Type != GameEvent.EventType.Kill && gameEvent.Type != GameEvent.EventType.Damage) - { - return; - } - - var eventRegex = gameEvent.Type == GameEvent.EventType.Kill - ? gameEvent.Owner.EventParser.Configuration.Kill - : gameEvent.Owner.EventParser.Configuration.Damage; - - var match = eventRegex.PatternMatcher.Match(gameEvent.Data); - - if (!match.Success) - { - _logger.LogWarning("Log for event type {type} does not match pattern {logLine}", gameEvent.Type, - gameEvent.Data); - return; - } - - var attackerHitInfo = _hitInfoBuilder.Build(match.Values.ToArray(), eventRegex, gameEvent.Origin.ClientId, - gameEvent.Origin.ClientId == gameEvent.Target.ClientId, false, gameEvent.Owner.GameName); - var victimHitInfo = _hitInfoBuilder.Build(match.Values.ToArray(), eventRegex, gameEvent.Target.ClientId, - gameEvent.Origin.ClientId == gameEvent.Target.ClientId, true, gameEvent.Owner.GameName); - - foreach (var hitInfo in new[] {attackerHitInfo, victimHitInfo}) - { - if (hitInfo.MeansOfDeath == null || hitInfo.Location == null || hitInfo.Weapon == null) - { - _logger.LogDebug("Skipping hit because it does not contain the required data"); - continue; - } - - try - { - await _onTransaction.WaitAsync(); - if (!_clientHitStatistics.ContainsKey(hitInfo.EntityId)) - { - _logger.LogDebug("Starting to track hits for {client}", hitInfo.EntityId); - var clientHits = await GetHitsForClient(hitInfo.EntityId); - _clientHitStatistics.TryAdd(hitInfo.EntityId, new HitState() - { - Hits = clientHits, - Server = (await _serverCache - .FirstAsync(server => - server.EndPoint == gameEvent.Owner.ToString() && server.HostName != null)) - }); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Could not retrieve previous hit data for client {Client}", hitInfo.EntityId); - continue; - } - - finally - { - if (_onTransaction.CurrentCount == 0) - { - _onTransaction.Release(); - } - } - - var state = _clientHitStatistics[hitInfo.EntityId]; - - try - { - await _onTransaction.WaitAsync(); - var calculatedHits = await RunTasksForHitInfo(hitInfo, state.Server.ServerId); - - foreach (var clientHit in calculatedHits) - { - RunCalculation(clientHit, hitInfo, state); - } - } - - catch (Exception ex) - { - _logger.LogError(ex, "Could not update hit calculations for {client}", hitInfo.EntityId); - } - - finally - { - if (_onTransaction.CurrentCount == 0) - { - _onTransaction.Release(); - } - } - } - } - - private async Task> RunTasksForHitInfo(HitInfo hitInfo, long? serverId) - { - var weapon = await GetOrAddWeapon(hitInfo.Weapon, hitInfo.Game); - var attachments = - await Task.WhenAll(hitInfo.Weapon.Attachments.Select(attachment => - GetOrAddAttachment(attachment, hitInfo.Game))); - var attachmentCombo = await GetOrAddAttachmentCombo(attachments, hitInfo.Game); - var matchingLocation = await GetOrAddHitLocation(hitInfo.Location, hitInfo.Game); - var meansOfDeath = await GetOrAddMeansOfDeath(hitInfo.MeansOfDeath, hitInfo.Game); - - var baseTasks = new[] - { - // just the client - GetOrAddClientHit(hitInfo.EntityId, null), - // client and server - GetOrAddClientHit(hitInfo.EntityId, serverId), - // just the location - GetOrAddClientHit(hitInfo.EntityId, null, matchingLocation.HitLocationId), - // location and server - GetOrAddClientHit(hitInfo.EntityId, serverId, matchingLocation.HitLocationId), - // per weapon - GetOrAddClientHit(hitInfo.EntityId, null, null, weapon.WeaponId), - // per weapon and server - GetOrAddClientHit(hitInfo.EntityId, serverId, null, weapon.WeaponId), - // means of death aggregate - GetOrAddClientHit(hitInfo.EntityId, meansOfDeathId: meansOfDeath.MeansOfDeathId), - // means of death per server aggregate - GetOrAddClientHit(hitInfo.EntityId, serverId, - meansOfDeathId: meansOfDeath.MeansOfDeathId) - }; - - var allTasks = baseTasks.AsEnumerable(); - - if (attachmentCombo != null) - { - allTasks = allTasks - // per weapon per attachment combo - .Append(GetOrAddClientHit(hitInfo.EntityId, null, null, - weapon.WeaponId, attachmentCombo.WeaponAttachmentComboId)) - .Append(GetOrAddClientHit(hitInfo.EntityId, serverId, null, - weapon.WeaponId, attachmentCombo.WeaponAttachmentComboId)); - } - - return await Task.WhenAll(allTasks); - } - - private void RunCalculation(EFClientHitStatistic clientHit, HitInfo hitInfo, HitState hitState) - { - if (hitInfo.HitType == HitType.Kill || hitInfo.HitType == HitType.Damage) - { - if (clientHit.WeaponId != null) // we only want to calculate usage time for weapons - { - var timeElapsed = DateTime.Now - hitState.LastUsage; - var isSameWeapon = clientHit.WeaponId == hitState.LastWeaponId; - - clientHit.UsageSeconds ??= 60; - - if (timeElapsed.HasValue && timeElapsed <= _maxActiveTime) - { - clientHit.UsageSeconds - += // if it's the same weapon we can count the entire elapsed time - // otherwise we split it to make a best guess - (int) Math.Round(timeElapsed.Value.TotalSeconds / (isSameWeapon ? 1.0 : 2.0)); - } - - hitState.LastUsage = DateTime.Now; - } - - clientHit.DamageInflicted += hitInfo.Damage; - clientHit.HitCount++; - } - - if (hitInfo.HitType == HitType.Kill) - { - clientHit.KillCount++; - } - - if (hitInfo.HitType == HitType.WasKilled || hitInfo.HitType == HitType.WasDamaged || - hitInfo.HitType == HitType.Suicide) - { - clientHit.ReceivedHitCount++; - clientHit.DamageReceived += hitInfo.Damage; - } - - if (hitInfo.HitType == HitType.WasKilled) - { - clientHit.DeathCount++; - } - } - - private async Task> GetHitsForClient(int clientId) - { - try - { - await using var context = _contextFactory.CreateContext(); - var hitLocations = await context.Set() - .Where(stat => stat.ClientId == clientId) - .ToListAsync(); - - return !hitLocations.Any() ? new List() : hitLocations; - } - catch (Exception ex) - { - _logger.LogError(ex, "Could not retrieve {hitName} for client with id {id}", - nameof(EFClientHitStatistic), clientId); - } - - return new List(); - } - - private async Task UpdateClientStatistics(int clientId, HitState locState = null) - { - if (!_clientHitStatistics.ContainsKey(clientId) && locState == null) - { - _logger.LogError("No {statsName} found for id {id}", nameof(EFClientHitStatistic), clientId); - return; - } - - var state = locState ?? _clientHitStatistics[clientId]; - try { - await using var context = _contextFactory.CreateContext(); - context.Set().UpdateRange(state.Hits); - await context.SaveChangesAsync(); + await state.OnTransaction.WaitAsync(); + HandleDisconnectCalculations(clientStateDisposeEvent.Client, state); + await UpdateClientStatistics(clientStateDisposeEvent.Client.ClientId, state); } catch (Exception ex) { - _logger.LogError(ex, "Could not update hit location stats for id {id}", clientId); - } - } - - private async Task GetOrAddClientHit(int clientId, long? serverId = null, - int? hitLocationId = null, int? weaponId = null, int? attachmentComboId = null, - int? meansOfDeathId = null) - { - var state = _clientHitStatistics[clientId]; - await state.OnTransaction.WaitAsync(); - - var hitStat = state.Hits - .FirstOrDefault(hit => hit.HitLocationId == hitLocationId - && hit.WeaponId == weaponId - && hit.WeaponAttachmentComboId == attachmentComboId - && hit.MeansOfDeathId == meansOfDeathId - && hit.ServerId == serverId); - - if (hitStat != null) - { - state.OnTransaction.Release(); - return hitStat; + _logger.LogError(ex, "Could not handle disconnect calculations for client {Client}", + clientStateDisposeEvent.Client.ToString()); } - hitStat = new EFClientHitStatistic() - { - ClientId = clientId, - ServerId = serverId, - WeaponId = weaponId, - WeaponAttachmentComboId = attachmentComboId, - HitLocationId = hitLocationId, - MeansOfDeathId = meansOfDeathId - }; - - try - { - /*if (state.UpdateCount > MaxUpdatesBeforePersist) - { - await UpdateClientStatistics(clientId); - state.UpdateCount = 0; - } - - state.UpdateCount++;*/ - state.Hits.Add(hitStat); - } - catch (Exception ex) - { - _logger.LogError(ex, "Could not add {statsName} for {id}", nameof(EFClientHitStatistic), - clientId); - state.Hits.Remove(hitStat); - } finally { if (state.OnTransaction.CurrentCount == 0) @@ -429,188 +141,479 @@ namespace IW4MAdmin.Plugins.Stats.Client } } - return hitStat; + return; } - private async Task GetOrAddHitLocation(string location, Reference.Game game) + if (coreEvent is MatchEndEvent matchEndEvent) { - var matchingLocation = (await _hitLocationCache - .FirstAsync(loc => loc.Name == location && loc.Game == game)); - - if (matchingLocation != null) + foreach (var client in matchEndEvent.Server.ConnectedClients) { - return matchingLocation; + var scores = client.GetAdditionalProperty>(SessionScores); + scores?.Add((client.GetAdditionalProperty(StatManager.ESTIMATED_SCORE) ?? client.Score, + DateTime.Now)); } - - var hitLocation = new EFHitLocation() - { - Name = location, - Game = game - }; - - hitLocation = await _hitLocationCache.AddAsync(hitLocation); - - return hitLocation; } - private async Task GetOrAddWeapon(WeaponInfo weapon, Reference.Game game) + var damageEvent = coreEvent as ClientKillEvent ?? coreEvent as ClientDamageEvent; + + if (damageEvent is null) { - var matchingWeapon = (await _weaponCache - .FirstAsync(wep => wep.Name == weapon.Name && wep.Game == game)); - - if (matchingWeapon != null) - { - return matchingWeapon; - } - - matchingWeapon = new EFWeapon() - { - Name = weapon.Name, - Game = game - }; - - matchingWeapon = await _weaponCache.AddAsync(matchingWeapon); - - return matchingWeapon; + return; } - private async Task GetOrAddAttachment(AttachmentInfo attachment, Reference.Game game) + var eventRegex = damageEvent is ClientKillEvent + ? damageEvent.Owner.EventParser.Configuration.Kill + : damageEvent.Owner.EventParser.Configuration.Damage; + + var match = eventRegex.PatternMatcher.Match(damageEvent.Data); + + if (!match.Success) { - var matchingAttachment = (await _attachmentCache - .FirstAsync(attach => attach.Name == attachment.Name && attach.Game == game)); - - if (matchingAttachment != null) - { - return matchingAttachment; - } - - matchingAttachment = new EFWeaponAttachment() - { - Name = attachment.Name, - Game = game - }; - - matchingAttachment = await _attachmentCache.AddAsync(matchingAttachment); - - return matchingAttachment; + _logger.LogWarning("Log for event type {Type} does not match pattern {LogLine}", damageEvent.Type, + damageEvent.Data); + return; } - private async Task GetOrAddAttachmentCombo(EFWeaponAttachment[] attachments, - Reference.Game game) + var attackerHitInfo = _hitInfoBuilder.Build(match.Values.ToArray(), eventRegex, damageEvent.Attacker.ClientId, + damageEvent.Attacker.ClientId == damageEvent.Victim.ClientId, false, damageEvent.Server.GameCode); + var victimHitInfo = _hitInfoBuilder.Build(match.Values.ToArray(), eventRegex, damageEvent.Victim.ClientId, + damageEvent.Attacker.ClientId == damageEvent.Victim.ClientId, true, damageEvent.Server.GameCode); + + foreach (var hitInfo in new[] {attackerHitInfo, victimHitInfo}) { - if (!attachments.Any()) + if (hitInfo.MeansOfDeath == null || hitInfo.Location == null || hitInfo.Weapon == null) { - return null; + _logger.LogDebug("Skipping hit because it does not contain the required data"); + continue; } - - var allAttachments = attachments.ToList(); - - if (allAttachments.Count() < 3) + + try { - for (var i = allAttachments.Count(); i <= 3; i++) + await _onTransaction.WaitAsync(); + if (!_clientHitStatistics.ContainsKey(hitInfo.EntityId)) { - allAttachments.Add(null); + _logger.LogDebug("Starting to track hits for {Client}", hitInfo.EntityId); + var clientHits = await GetHitsForClient(hitInfo.EntityId); + _clientHitStatistics.TryAdd(hitInfo.EntityId, new HitState + { + Hits = clientHits, + Server = await _serverCache + .FirstAsync(server => + server.EndPoint == damageEvent.Server.Id && server.HostName != null) + }); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not retrieve previous hit data for client {Client}", hitInfo.EntityId); + continue; + } + + finally + { + if (_onTransaction.CurrentCount == 0) + { + _onTransaction.Release(); } } - var matchingAttachmentCombo = (await _attachmentComboCache.FirstAsync(combo => - combo.Game == game - && combo.Attachment1Id == allAttachments[0].Id - && combo.Attachment2Id == allAttachments[1]?.Id - && combo.Attachment3Id == allAttachments[2]?.Id)); + var state = _clientHitStatistics[hitInfo.EntityId]; - if (matchingAttachmentCombo != null) + try { - return matchingAttachmentCombo; - } + await _onTransaction.WaitAsync(); + var calculatedHits = await RunTasksForHitInfo(hitInfo, state.Server.ServerId); - matchingAttachmentCombo = new EFWeaponAttachmentCombo() - { - Game = game, - Attachment1Id = (int) allAttachments[0].Id, - Attachment2Id = (int?) allAttachments[1]?.Id, - Attachment3Id = (int?) allAttachments[2]?.Id, - }; - - matchingAttachmentCombo = await _attachmentComboCache.AddAsync(matchingAttachmentCombo); - - return matchingAttachmentCombo; - } - - private async Task GetOrAddMeansOfDeath(string meansOfDeath, Reference.Game game) - { - var matchingMod = (await _modCache - .FirstAsync(mod => mod.Name == meansOfDeath && mod.Game == game)); - - if (matchingMod != null) - { - return matchingMod; - } - - var mod = new EFMeansOfDeath() - { - Name = meansOfDeath, - Game = game - }; - - mod = await _modCache.AddAsync(mod); - - return mod; - } - - private void HandleDisconnectCalculations(EFClient client, HitState state) - { - // todo: this not added to states fast connect/disconnect - var serverStats = state.Hits.FirstOrDefault(stat => - stat.ServerId == state.Server.ServerId && stat.WeaponId == null && - stat.WeaponAttachmentComboId == null && stat.HitLocationId == null && stat.MeansOfDeathId == null); - - if (serverStats == null) - { - _logger.LogWarning("No server hits were found for {serverId} on disconnect for {client}", - state.Server.ServerId, client.ToString()); - return; - } - - var aggregate = state.Hits.FirstOrDefault(stat => stat.WeaponId == null && - stat.WeaponAttachmentComboId == null && - stat.HitLocationId == null && - stat.MeansOfDeathId == null && - stat.ServerId == null); - - if (aggregate == null) - { - _logger.LogWarning("No aggregate found for {serverId} on disconnect for {client}", - state.Server.ServerId, client.ToString()); - return; - } - - var sessionScores = client.GetAdditionalProperty>(SessionScores); - - if (sessionScores == null) - { - _logger.LogWarning("No session scores available for {Client}", client.ToString()); - return; - } - - foreach (var stat in new[] {serverStats, aggregate}) - { - stat.Score ??= 0; - - if (sessionScores.Count == 0) + foreach (var clientHit in calculatedHits) { - stat.Score += client.Score > 0 ? client.Score : client.GetAdditionalProperty(Helpers.StatManager.ESTIMATED_SCORE) ?? 0 * 50; + RunCalculation(clientHit, hitInfo, state); } + } - else + catch (Exception ex) + { + _logger.LogError(ex, "Could not update hit calculations for {Client}", hitInfo.EntityId); + } + + finally + { + if (_onTransaction.CurrentCount == 0) { - stat.Score += sessionScores.Sum(item => item.Item1) + - (sessionScores.Last().Item1 == client.Score && - (DateTime.Now - sessionScores.Last().Item2).TotalMinutes < 1 - ? 0 - : client.Score); + _onTransaction.Release(); } } } } + + private async Task> RunTasksForHitInfo(HitInfo hitInfo, long? serverId) + { + var weapon = await GetOrAddWeapon(hitInfo.Weapon, hitInfo.Game); + var attachments = + await Task.WhenAll(hitInfo.Weapon.Attachments.Select(attachment => + GetOrAddAttachment(attachment, hitInfo.Game))); + var attachmentCombo = await GetOrAddAttachmentCombo(attachments, hitInfo.Game); + var matchingLocation = await GetOrAddHitLocation(hitInfo.Location, hitInfo.Game); + var meansOfDeath = await GetOrAddMeansOfDeath(hitInfo.MeansOfDeath, hitInfo.Game); + + var baseTasks = new[] + { + // just the client + GetOrAddClientHit(hitInfo.EntityId, null), + // client and server + GetOrAddClientHit(hitInfo.EntityId, serverId), + // just the location + GetOrAddClientHit(hitInfo.EntityId, null, matchingLocation.HitLocationId), + // location and server + GetOrAddClientHit(hitInfo.EntityId, serverId, matchingLocation.HitLocationId), + // per weapon + GetOrAddClientHit(hitInfo.EntityId, null, null, weapon.WeaponId), + // per weapon and server + GetOrAddClientHit(hitInfo.EntityId, serverId, null, weapon.WeaponId), + // means of death aggregate + GetOrAddClientHit(hitInfo.EntityId, meansOfDeathId: meansOfDeath.MeansOfDeathId), + // means of death per server aggregate + GetOrAddClientHit(hitInfo.EntityId, serverId, + meansOfDeathId: meansOfDeath.MeansOfDeathId) + }; + + var allTasks = baseTasks.AsEnumerable(); + + if (attachmentCombo != null) + { + allTasks = allTasks + // per weapon per attachment combo + .Append(GetOrAddClientHit(hitInfo.EntityId, null, null, + weapon.WeaponId, attachmentCombo.WeaponAttachmentComboId)) + .Append(GetOrAddClientHit(hitInfo.EntityId, serverId, null, + weapon.WeaponId, attachmentCombo.WeaponAttachmentComboId)); + } + + return await Task.WhenAll(allTasks); + } + + private void RunCalculation(EFClientHitStatistic clientHit, HitInfo hitInfo, HitState hitState) + { + if (hitInfo.HitType == HitType.Kill || hitInfo.HitType == HitType.Damage) + { + if (clientHit.WeaponId != null) // we only want to calculate usage time for weapons + { + var timeElapsed = DateTime.Now - hitState.LastUsage; + var isSameWeapon = clientHit.WeaponId == hitState.LastWeaponId; + + clientHit.UsageSeconds ??= 60; + + if (timeElapsed.HasValue && timeElapsed <= _maxActiveTime) + { + clientHit.UsageSeconds + += // if it's the same weapon we can count the entire elapsed time + // otherwise we split it to make a best guess + (int) Math.Round(timeElapsed.Value.TotalSeconds / (isSameWeapon ? 1.0 : 2.0)); + } + + hitState.LastUsage = DateTime.Now; + } + + clientHit.DamageInflicted += hitInfo.Damage; + clientHit.HitCount++; + } + + if (hitInfo.HitType == HitType.Kill) + { + clientHit.KillCount++; + } + + if (hitInfo.HitType == HitType.WasKilled || hitInfo.HitType == HitType.WasDamaged || + hitInfo.HitType == HitType.Suicide) + { + clientHit.ReceivedHitCount++; + clientHit.DamageReceived += hitInfo.Damage; + } + + if (hitInfo.HitType == HitType.WasKilled) + { + clientHit.DeathCount++; + } + } + + private async Task> GetHitsForClient(int clientId) + { + try + { + await using var context = _contextFactory.CreateContext(); + var hitLocations = await context.Set() + .Where(stat => stat.ClientId == clientId) + .ToListAsync(); + + return !hitLocations.Any() ? new List() : hitLocations; + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not retrieve {hitName} for client with id {id}", + nameof(EFClientHitStatistic), clientId); + } + + return new List(); + } + + private async Task UpdateClientStatistics(int clientId, HitState locState = null) + { + if (!_clientHitStatistics.ContainsKey(clientId) && locState == null) + { + _logger.LogError("No {statsName} found for id {id}", nameof(EFClientHitStatistic), clientId); + return; + } + + var state = locState ?? _clientHitStatistics[clientId]; + + try + { + await using var context = _contextFactory.CreateContext(); + context.Set().UpdateRange(state.Hits); + await context.SaveChangesAsync(); + } + + catch (Exception ex) + { + _logger.LogError(ex, "Could not update hit location stats for id {id}", clientId); + } + } + + private async Task GetOrAddClientHit(int clientId, long? serverId = null, + int? hitLocationId = null, int? weaponId = null, int? attachmentComboId = null, + int? meansOfDeathId = null) + { + var state = _clientHitStatistics[clientId]; + await state.OnTransaction.WaitAsync(); + + var hitStat = state.Hits + .FirstOrDefault(hit => hit.HitLocationId == hitLocationId + && hit.WeaponId == weaponId + && hit.WeaponAttachmentComboId == attachmentComboId + && hit.MeansOfDeathId == meansOfDeathId + && hit.ServerId == serverId); + + if (hitStat != null) + { + state.OnTransaction.Release(); + return hitStat; + } + + hitStat = new EFClientHitStatistic() + { + ClientId = clientId, + ServerId = serverId, + WeaponId = weaponId, + WeaponAttachmentComboId = attachmentComboId, + HitLocationId = hitLocationId, + MeansOfDeathId = meansOfDeathId + }; + + try + { + /*if (state.UpdateCount > MaxUpdatesBeforePersist) + { + await UpdateClientStatistics(clientId); + state.UpdateCount = 0; + } + + state.UpdateCount++;*/ + state.Hits.Add(hitStat); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not add {statsName} for {id}", nameof(EFClientHitStatistic), + clientId); + state.Hits.Remove(hitStat); + } + finally + { + if (state.OnTransaction.CurrentCount == 0) + { + state.OnTransaction.Release(); + } + } + + return hitStat; + } + + private async Task GetOrAddHitLocation(string location, Reference.Game game) + { + var matchingLocation = (await _hitLocationCache + .FirstAsync(loc => loc.Name == location && loc.Game == game)); + + if (matchingLocation != null) + { + return matchingLocation; + } + + var hitLocation = new EFHitLocation() + { + Name = location, + Game = game + }; + + hitLocation = await _hitLocationCache.AddAsync(hitLocation); + + return hitLocation; + } + + private async Task GetOrAddWeapon(WeaponInfo weapon, Reference.Game game) + { + var matchingWeapon = (await _weaponCache + .FirstAsync(wep => wep.Name == weapon.Name && wep.Game == game)); + + if (matchingWeapon != null) + { + return matchingWeapon; + } + + matchingWeapon = new EFWeapon() + { + Name = weapon.Name, + Game = game + }; + + matchingWeapon = await _weaponCache.AddAsync(matchingWeapon); + + return matchingWeapon; + } + + private async Task GetOrAddAttachment(AttachmentInfo attachment, Reference.Game game) + { + var matchingAttachment = (await _attachmentCache + .FirstAsync(attach => attach.Name == attachment.Name && attach.Game == game)); + + if (matchingAttachment != null) + { + return matchingAttachment; + } + + matchingAttachment = new EFWeaponAttachment() + { + Name = attachment.Name, + Game = game + }; + + matchingAttachment = await _attachmentCache.AddAsync(matchingAttachment); + + return matchingAttachment; + } + + private async Task GetOrAddAttachmentCombo(EFWeaponAttachment[] attachments, + Reference.Game game) + { + if (!attachments.Any()) + { + return null; + } + + var allAttachments = attachments.ToList(); + + if (allAttachments.Count() < 3) + { + for (var i = allAttachments.Count(); i <= 3; i++) + { + allAttachments.Add(null); + } + } + + var matchingAttachmentCombo = (await _attachmentComboCache.FirstAsync(combo => + combo.Game == game + && combo.Attachment1Id == allAttachments[0].Id + && combo.Attachment2Id == allAttachments[1]?.Id + && combo.Attachment3Id == allAttachments[2]?.Id)); + + if (matchingAttachmentCombo != null) + { + return matchingAttachmentCombo; + } + + matchingAttachmentCombo = new EFWeaponAttachmentCombo() + { + Game = game, + Attachment1Id = (int) allAttachments[0].Id, + Attachment2Id = (int?) allAttachments[1]?.Id, + Attachment3Id = (int?) allAttachments[2]?.Id, + }; + + matchingAttachmentCombo = await _attachmentComboCache.AddAsync(matchingAttachmentCombo); + + return matchingAttachmentCombo; + } + + private async Task GetOrAddMeansOfDeath(string meansOfDeath, Reference.Game game) + { + var matchingMod = (await _modCache + .FirstAsync(mod => mod.Name == meansOfDeath && mod.Game == game)); + + if (matchingMod != null) + { + return matchingMod; + } + + var mod = new EFMeansOfDeath() + { + Name = meansOfDeath, + Game = game + }; + + mod = await _modCache.AddAsync(mod); + + return mod; + } + + private void HandleDisconnectCalculations(EFClient client, HitState state) + { + // todo: this not added to states fast connect/disconnect + var serverStats = state.Hits.FirstOrDefault(stat => + stat.ServerId == state.Server.ServerId && stat.WeaponId == null && + stat.WeaponAttachmentComboId == null && stat.HitLocationId == null && stat.MeansOfDeathId == null); + + if (serverStats == null) + { + _logger.LogWarning("No server hits were found for {serverId} on disconnect for {client}", + state.Server.ServerId, client.ToString()); + return; + } + + var aggregate = state.Hits.FirstOrDefault(stat => stat.WeaponId == null && + stat.WeaponAttachmentComboId == null && + stat.HitLocationId == null && + stat.MeansOfDeathId == null && + stat.ServerId == null); + + if (aggregate == null) + { + _logger.LogWarning("No aggregate found for {serverId} on disconnect for {client}", + state.Server.ServerId, client.ToString()); + return; + } + + var sessionScores = client.GetAdditionalProperty>(SessionScores); + + if (sessionScores == null) + { + _logger.LogWarning("No session scores available for {Client}", client.ToString()); + return; + } + + foreach (var stat in new[] {serverStats, aggregate}) + { + stat.Score ??= 0; + + if (sessionScores.Count == 0) + { + stat.Score += client.Score > 0 ? client.Score : client.GetAdditionalProperty(Helpers.StatManager.ESTIMATED_SCORE) ?? 0 * 50; + } + + else + { + stat.Score += sessionScores.Sum(item => item.Item1) + + (sessionScores.Last().Item1 == client.Score && + (DateTime.Now - sessionScores.Last().Item2).TotalMinutes < 1 + ? 0 + : client.Score); + } + } + } } diff --git a/Plugins/Stats/Client/HitInfoBuilder.cs b/Plugins/Stats/Client/HitInfoBuilder.cs index 147b1f419..e9863a8e2 100644 --- a/Plugins/Stats/Client/HitInfoBuilder.cs +++ b/Plugins/Stats/Client/HitInfoBuilder.cs @@ -3,82 +3,80 @@ using System.Linq; using Data.Models; using IW4MAdmin.Plugins.Stats.Client.Game; using Microsoft.Extensions.Logging; -using SharedLibraryCore; using SharedLibraryCore.Interfaces; using Stats.Client.Abstractions; using Stats.Client.Game; using ILogger = Microsoft.Extensions.Logging.ILogger; -namespace Stats.Client +namespace Stats.Client; + +public class HitInfoBuilder : IHitInfoBuilder { - public class HitInfoBuilder : IHitInfoBuilder + private readonly IWeaponNameParser _weaponNameParser; + private readonly ILogger _logger; + private const int MaximumDamage = 1000; + + public HitInfoBuilder(ILogger logger, IWeaponNameParser weaponNameParser) { - private readonly IWeaponNameParser _weaponNameParser; - private readonly ILogger _logger; - private const int MaximumDamage = 1000; + _weaponNameParser = weaponNameParser; + _logger = logger; + } - public HitInfoBuilder(ILogger logger, IWeaponNameParser weaponNameParser) + public HitInfo Build(string[] log, ParserRegex parserRegex, int entityId, bool isSelf, bool isVictim, + Reference.Game gameName) + { + var eventType = log[(uint)ParserRegex.GroupType.EventType].First(); + HitType hitType; + + if (isVictim) { - _weaponNameParser = weaponNameParser; - _logger = logger; - } - - public HitInfo Build(string[] log, ParserRegex parserRegex, int entityId, bool isSelf, bool isVictim, - Server.Game gameName) - { - var eventType = log[(uint) ParserRegex.GroupType.EventType].First(); - HitType hitType; - - if (isVictim) + if (isSelf) { - if (isSelf) - { - hitType = HitType.Suicide; - } - - else - { - hitType = eventType == 'D' ? HitType.WasDamaged : HitType.WasKilled; - } + hitType = HitType.Suicide; } else { - hitType = eventType == 'D' ? HitType.Damage : HitType.Kill; + hitType = eventType == 'D' ? HitType.WasDamaged : HitType.WasKilled; } - - var damage = 0; - try - { - damage = Math.Min(MaximumDamage, - log.Length > parserRegex.GroupMapping[ParserRegex.GroupType.Damage] - ? int.Parse(log[parserRegex.GroupMapping[ParserRegex.GroupType.Damage]]) - : 0); - } - catch - { - // ignored - } - - var hitInfo = new HitInfo() - { - EntityId = entityId, - IsVictim = isVictim, - HitType = hitType, - Damage = damage, - Location = log.Length > parserRegex.GroupMapping[ParserRegex.GroupType.HitLocation] - ? log[parserRegex.GroupMapping[ParserRegex.GroupType.HitLocation]] - : "Unknown", - Weapon = log.Length > parserRegex.GroupMapping[ParserRegex.GroupType.Weapon] - ? _weaponNameParser.Parse(log[parserRegex.GroupMapping[ParserRegex.GroupType.Weapon]], gameName) - : new WeaponInfo {Name = "Unknown"}, - MeansOfDeath = log.Length > parserRegex.GroupMapping[ParserRegex.GroupType.MeansOfDeath] - ? log[parserRegex.GroupMapping[ParserRegex.GroupType.MeansOfDeath]] - : "Unknown", - Game = (Reference.Game) gameName - }; - - return hitInfo; } + + else + { + hitType = eventType == 'D' ? HitType.Damage : HitType.Kill; + } + + var damage = 0; + try + { + damage = Math.Min(MaximumDamage, + log.Length > parserRegex.GroupMapping[ParserRegex.GroupType.Damage] + ? int.Parse(log[parserRegex.GroupMapping[ParserRegex.GroupType.Damage]]) + : 0); + } + catch + { + // ignored + } + + var hitInfo = new HitInfo() + { + EntityId = entityId, + IsVictim = isVictim, + HitType = hitType, + Damage = damage, + Location = log.Length > parserRegex.GroupMapping[ParserRegex.GroupType.HitLocation] + ? log[parserRegex.GroupMapping[ParserRegex.GroupType.HitLocation]] + : "Unknown", + Weapon = log.Length > parserRegex.GroupMapping[ParserRegex.GroupType.Weapon] + ? _weaponNameParser.Parse(log[parserRegex.GroupMapping[ParserRegex.GroupType.Weapon]], gameName) + : new WeaponInfo { Name = "Unknown" }, + MeansOfDeath = log.Length > parserRegex.GroupMapping[ParserRegex.GroupType.MeansOfDeath] + ? log[parserRegex.GroupMapping[ParserRegex.GroupType.MeansOfDeath]] + : "Unknown", + Game = gameName + }; + + return hitInfo; } -} \ No newline at end of file +} diff --git a/Plugins/Stats/Client/WeaponNameParser.cs b/Plugins/Stats/Client/WeaponNameParser.cs index 88184c377..53e4c6f45 100644 --- a/Plugins/Stats/Client/WeaponNameParser.cs +++ b/Plugins/Stats/Client/WeaponNameParser.cs @@ -3,7 +3,7 @@ using Stats.Client.Abstractions; using Stats.Client.Game; using System.Collections.Generic; using System.Linq; -using SharedLibraryCore; +using Data.Models; using Stats.Config; using ILogger = Microsoft.Extensions.Logging.ILogger; @@ -20,7 +20,7 @@ namespace Stats.Client _config = config; } - public WeaponInfo Parse(string weaponName, Server.Game gameName) + public WeaponInfo Parse(string weaponName, Reference.Game gameName) { var configForGame = _config.WeaponNameParserConfigurations ?.FirstOrDefault(config => config.Game == gameName) ?? new WeaponNameParserConfiguration() diff --git a/Plugins/Stats/Commands/MostKillsCommand.cs b/Plugins/Stats/Commands/MostKillsCommand.cs index 354cf5c02..92336ab0b 100644 --- a/Plugins/Stats/Commands/MostKillsCommand.cs +++ b/Plugins/Stats/Commands/MostKillsCommand.cs @@ -12,69 +12,70 @@ using SharedLibraryCore.Interfaces; using IW4MAdmin.Plugins.Stats.Helpers; using Stats.Config; -namespace IW4MAdmin.Plugins.Stats.Commands +namespace IW4MAdmin.Plugins.Stats.Commands; + +class MostKillsCommand : Command { - class MostKillsCommand : Command + private readonly IDatabaseContextFactory _contextFactory; + private readonly StatsConfiguration _statsConfig; + + public MostKillsCommand(CommandConfiguration config, ITranslationLookup translationLookup, + IDatabaseContextFactory contextFactory, StatsConfiguration statsConfig) : base(config, translationLookup) { - private readonly IDatabaseContextFactory _contextFactory; + Name = "mostkills"; + Description = translationLookup["PLUGINS_STATS_COMMANDS_MOSTKILLS_DESC"]; + Alias = "mk"; + Permission = EFClient.Permission.User; - public MostKillsCommand(CommandConfiguration config, ITranslationLookup translationLookup, - IDatabaseContextFactory contextFactory) : base(config, translationLookup) + _contextFactory = contextFactory; + _statsConfig = statsConfig; + } + + public override async Task ExecuteAsync(GameEvent gameEvent) + { + var mostKills = await GetMostKills(StatManager.GetIdForServer(gameEvent.Owner), _statsConfig, + _contextFactory, _translationLookup); + if (!gameEvent.Message.IsBroadcastCommand(_config.BroadcastCommandPrefix)) { - Name = "mostkills"; - Description = translationLookup["PLUGINS_STATS_COMMANDS_MOSTKILLS_DESC"]; - Alias = "mk"; - Permission = EFClient.Permission.User; - - _contextFactory = contextFactory; + await gameEvent.Origin.TellAsync(mostKills, gameEvent.Owner.Manager.CancellationToken); } - public override async Task ExecuteAsync(GameEvent gameEvent) + else { - var mostKills = await GetMostKills(StatManager.GetIdForServer(gameEvent.Owner), Plugin.Config.Configuration(), - _contextFactory, _translationLookup); - if (!gameEvent.Message.IsBroadcastCommand(_config.BroadcastCommandPrefix)) + foreach (var stat in mostKills) { - await gameEvent.Origin.TellAsync(mostKills, gameEvent.Owner.Manager.CancellationToken); + await gameEvent.Owner.Broadcast(stat).WaitAsync(Utilities.DefaultCommandTimeout, + gameEvent.Owner.Manager.CancellationToken); } - - else - { - foreach (var stat in mostKills) - { - await gameEvent.Owner.Broadcast(stat).WaitAsync(Utilities.DefaultCommandTimeout, - gameEvent.Owner.Manager.CancellationToken); - } - } - } - - public static async Task> GetMostKills(long? serverId, StatsConfiguration config, - IDatabaseContextFactory contextFactory, ITranslationLookup translationLookup) - { - await using var ctx = contextFactory.CreateContext(enableTracking: false); - var dayInPast = DateTime.UtcNow.AddDays(-config.MostKillsMaxInactivityDays); - - var iqStats = (from stats in ctx.Set() - join client in ctx.Clients - on stats.ClientId equals client.ClientId - join alias in ctx.Aliases - on client.CurrentAliasId equals alias.AliasId - where stats.ServerId == serverId - where client.Level != EFClient.Permission.Banned - where client.LastConnection >= dayInPast - orderby stats.Kills descending - select new - { - alias.Name, - stats.Kills - }) - .Take(config.MostKillsClientLimit); - - var iqList = await iqStats.ToListAsync(); - - return iqList.Select((stats, index) => translationLookup["PLUGINS_STATS_COMMANDS_MOSTKILLS_FORMAT_V2"] - .FormatExt(index + 1, stats.Name, stats.Kills)) - .Prepend(Utilities.CurrentLocalization.LocalizationIndex["PLUGINS_STATS_COMMANDS_MOSTKILLS_HEADER"]); } } + + public static async Task> GetMostKills(long? serverId, StatsConfiguration config, + IDatabaseContextFactory contextFactory, ITranslationLookup translationLookup) + { + await using var ctx = contextFactory.CreateContext(enableTracking: false); + var dayInPast = DateTime.UtcNow.AddDays(-config.MostKillsMaxInactivityDays); + + var iqStats = (from stats in ctx.Set() + join client in ctx.Clients + on stats.ClientId equals client.ClientId + join alias in ctx.Aliases + on client.CurrentAliasId equals alias.AliasId + where stats.ServerId == serverId + where client.Level != EFClient.Permission.Banned + where client.LastConnection >= dayInPast + orderby stats.Kills descending + select new + { + alias.Name, + stats.Kills + }) + .Take(config.MostKillsClientLimit); + + var iqList = await iqStats.ToListAsync(); + + return iqList.Select((stats, index) => translationLookup["PLUGINS_STATS_COMMANDS_MOSTKILLS_FORMAT_V2"] + .FormatExt(index + 1, stats.Name, stats.Kills)) + .Prepend(Utilities.CurrentLocalization.LocalizationIndex["PLUGINS_STATS_COMMANDS_MOSTKILLS_HEADER"]); + } } diff --git a/Plugins/Stats/Commands/ResetStats.cs b/Plugins/Stats/Commands/ResetStats.cs index 69395ceab..4b21cea8d 100644 --- a/Plugins/Stats/Commands/ResetStats.cs +++ b/Plugins/Stats/Commands/ResetStats.cs @@ -7,15 +7,18 @@ using System.Linq; using System.Threading.Tasks; using Data.Abstractions; using Data.Models.Client.Stats; +using IW4MAdmin.Plugins.Stats.Helpers; +using Stats.Config; namespace IW4MAdmin.Plugins.Stats.Commands { public class ResetStats : Command { private readonly IDatabaseContextFactory _contextFactory; - + private readonly StatManager _statManager; + public ResetStats(CommandConfiguration config, ITranslationLookup translationLookup, - IDatabaseContextFactory contextFactory) : base(config, translationLookup) + IDatabaseContextFactory contextFactory, StatManager statManager) : base(config, translationLookup) { Name = "resetstats"; Description = translationLookup["PLUGINS_STATS_COMMANDS_RESET_DESC"]; @@ -25,6 +28,7 @@ namespace IW4MAdmin.Plugins.Stats.Commands AllowImpersonation = true; _contextFactory = contextFactory; + _statManager = statManager; } public override async Task ExecuteAsync(GameEvent gameEvent) @@ -53,7 +57,7 @@ namespace IW4MAdmin.Plugins.Stats.Commands } // reset the cached version - Plugin.Manager.ResetStats(gameEvent.Origin); + _statManager.ResetStats(gameEvent.Origin); gameEvent.Origin.Tell(_translationLookup["PLUGINS_STATS_COMMANDS_RESET_SUCCESS"]); } diff --git a/Plugins/Stats/Commands/TopStats.cs b/Plugins/Stats/Commands/TopStats.cs index cf012f9a5..fb1a920d8 100644 --- a/Plugins/Stats/Commands/TopStats.cs +++ b/Plugins/Stats/Commands/TopStats.cs @@ -11,15 +11,15 @@ namespace IW4MAdmin.Plugins.Stats.Commands { public class TopStats : Command { - public static async Task> GetTopStats(Server s, ITranslationLookup translationLookup) + public static async Task> GetTopStats(IGameServer server, ITranslationLookup translationLookup, StatManager statManager) { - var serverId = StatManager.GetIdForServer(s); + var serverId = StatManager.GetIdForServer(server); var topStatsText = new List() { $"(Color::Accent)--{translationLookup["PLUGINS_STATS_COMMANDS_TOP_TEXT"]}--" }; - var stats = await Plugin.Manager.GetTopStats(0, 5, serverId); + var stats = await statManager.GetTopStats(0, 5, serverId); var statsList = stats.Select((stats, index) => translationLookup["COMMANDS_TOPSTATS_RESULT"] .FormatExt(index + 1, stats.Name, stats.KDR, stats.Performance)); @@ -39,8 +39,9 @@ namespace IW4MAdmin.Plugins.Stats.Commands } private new readonly CommandConfiguration _config; + private readonly StatManager _statManager; - public TopStats(CommandConfiguration config, ITranslationLookup translationLookup) : base(config, + public TopStats(CommandConfiguration config, ITranslationLookup translationLookup, StatManager statManager) : base(config, translationLookup) { Name = "topstats"; @@ -50,11 +51,12 @@ namespace IW4MAdmin.Plugins.Stats.Commands RequiresTarget = false; _config = config; + _statManager = statManager; } public override async Task ExecuteAsync(GameEvent gameEvent) { - var topStats = await GetTopStats(gameEvent.Owner, _translationLookup); + var topStats = await GetTopStats(gameEvent.Owner, _translationLookup, _statManager); if (!gameEvent.Message.IsBroadcastCommand(_config.BroadcastCommandPrefix)) { await gameEvent.Origin.TellAsync(topStats, gameEvent.Owner.Manager.CancellationToken); diff --git a/Plugins/Stats/Commands/ViewStats.cs b/Plugins/Stats/Commands/ViewStats.cs index 3935d33ee..b0de9fd27 100644 --- a/Plugins/Stats/Commands/ViewStats.cs +++ b/Plugins/Stats/Commands/ViewStats.cs @@ -15,9 +15,10 @@ namespace IW4MAdmin.Plugins.Stats.Commands public class ViewStatsCommand : Command { private readonly IDatabaseContextFactory _contextFactory; + private readonly StatManager _statManager; public ViewStatsCommand(CommandConfiguration config, ITranslationLookup translationLookup, - IDatabaseContextFactory contextFactory) : base(config, translationLookup) + IDatabaseContextFactory contextFactory, StatManager statManager) : base(config, translationLookup) { Name = "stats"; Description = translationLookup["PLUGINS_STATS_COMMANDS_VIEW_DESC"]; @@ -34,6 +35,7 @@ namespace IW4MAdmin.Plugins.Stats.Commands }; _contextFactory = contextFactory; + _statManager = statManager; } public override async Task ExecuteAsync(GameEvent E) @@ -53,12 +55,12 @@ namespace IW4MAdmin.Plugins.Stats.Commands var serverId = StatManager.GetIdForServer(E.Owner); - var totalRankedPlayers = await Plugin.Manager.GetTotalRankedPlayers(serverId); + var totalRankedPlayers = await _statManager.GetTotalRankedPlayers(serverId); // getting stats for a particular client if (E.Target != null) { - var performanceRanking = await Plugin.Manager.GetClientOverallRanking(E.Target.ClientId, serverId); + var performanceRanking = await _statManager.GetClientOverallRanking(E.Target.ClientId, serverId); var performanceRankingString = performanceRanking == 0 ? _translationLookup["WEBFRONT_STATS_INDEX_UNRANKED"] : $"{_translationLookup["WEBFRONT_STATS_INDEX_RANKED"]} (Color::Accent)#{performanceRanking}/{totalRankedPlayers}"; @@ -87,7 +89,7 @@ namespace IW4MAdmin.Plugins.Stats.Commands // getting self stats else { - var performanceRanking = await Plugin.Manager.GetClientOverallRanking(E.Origin.ClientId, serverId); + var performanceRanking = await _statManager.GetClientOverallRanking(E.Origin.ClientId, serverId); var performanceRankingString = performanceRanking == 0 ? _translationLookup["WEBFRONT_STATS_INDEX_UNRANKED"] : $"{_translationLookup["WEBFRONT_STATS_INDEX_RANKED"]} (Color::Accent)#{performanceRanking}/{totalRankedPlayers}"; @@ -131,4 +133,4 @@ namespace IW4MAdmin.Plugins.Stats.Commands } } } -} \ No newline at end of file +} diff --git a/Plugins/Stats/Config/StatsConfiguration.cs b/Plugins/Stats/Config/StatsConfiguration.cs index f7bd2f61d..024b5008e 100644 --- a/Plugins/Stats/Config/StatsConfiguration.cs +++ b/Plugins/Stats/Config/StatsConfiguration.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Data.Models; using IW4MAdmin.Plugins.Stats.Config; using SharedLibraryCore; using SharedLibraryCore.Interfaces; @@ -21,26 +22,26 @@ namespace Stats.Config public WeaponNameParserConfiguration[] WeaponNameParserConfigurations { get; set; } = { new() { - Game = Server.Game.IW3, + Game = Reference.Game.IW3, WeaponSuffix = "mp", Delimiters = new[] {'_'} }, new() { - Game = Server.Game.IW4, + Game = Reference.Game.IW4, WeaponSuffix = "mp", Delimiters = new[] {'_'} }, new() { - Game = Server.Game.IW5, + Game = Reference.Game.IW5, WeaponSuffix = "mp", WeaponPrefix = "iw5", Delimiters = new[] {'_'} }, new() { - Game = Server.Game.T6, + Game = Reference.Game.T6, WeaponSuffix = "mp", Delimiters = new[] {'_', '+'} } diff --git a/Plugins/Stats/Config/WeaponNameParserConfiguration.cs b/Plugins/Stats/Config/WeaponNameParserConfiguration.cs index 84e5e8034..c9ae1e8e4 100644 --- a/Plugins/Stats/Config/WeaponNameParserConfiguration.cs +++ b/Plugins/Stats/Config/WeaponNameParserConfiguration.cs @@ -1,12 +1,12 @@ -using SharedLibraryCore; +using Data.Models; namespace Stats.Config { public class WeaponNameParserConfiguration { - public Server.Game Game { get; set; } + public Reference.Game Game { get; set; } public char[] Delimiters { get; set; } public string WeaponSuffix { get; set; } public string WeaponPrefix { get; set; } } -} \ No newline at end of file +} diff --git a/Plugins/Stats/Events/Script.cs b/Plugins/Stats/Events/Script.cs index b4eeb2e45..f52ef9030 100644 --- a/Plugins/Stats/Events/Script.cs +++ b/Plugins/Stats/Events/Script.cs @@ -2,111 +2,101 @@ using SharedLibraryCore.Database.Models; using SharedLibraryCore.Interfaces; using System.Collections.Generic; -using EventGeneratorCallback = System.ValueTuple>; + SharedLibraryCore.GameEvent, + SharedLibraryCore.GameEvent>>; namespace IW4MAdmin.Plugins.Stats.Events { public class Script : IRegisterEvent { - private const string EVENT_SCRIPTKILL = "ScriptKill"; - private const string EVENT_SCRIPTDAMAGE = "ScriptDamage"; - private const string EVENT_JOINTEAM = "JoinTeam"; + private const string EventScriptKill = "ScriptKill"; + private const string EventScriptDamage = "ScriptDamage"; /// /// this is a custom event printed out by _customcallbacks.gsc (used for anticheat) /// /// - private EventGeneratorCallback ScriptKill() + private static EventGeneratorCallback ScriptKill() { - return (EVENT_SCRIPTKILL, EVENT_SCRIPTKILL, (string eventLine, IEventParserConfiguration config, GameEvent autoEvent) => - { - string[] lineSplit = eventLine.Split(";"); + return (EventScriptKill, EventScriptKill, + (eventLine, config, autoEvent) => + { + var lineSplit = eventLine.Split(";"); - if (lineSplit[1].IsBotGuid() || lineSplit[2].IsBotGuid()) - { - return autoEvent; - } + if (lineSplit[1].IsBotGuid() || lineSplit[2].IsBotGuid()) + { + return autoEvent; + } - long originId = lineSplit[1].ConvertGuidToLong(config.GuidNumberStyle, 1); - long targetId = lineSplit[2].ConvertGuidToLong(config.GuidNumberStyle, 1); + var originId = lineSplit[1].ConvertGuidToLong(config.GuidNumberStyle, 1); + var targetId = lineSplit[2].ConvertGuidToLong(config.GuidNumberStyle, 1); - autoEvent.Type = GameEvent.EventType.ScriptKill; - autoEvent.Origin = new EFClient() { NetworkId = originId }; - autoEvent.Target = new EFClient() { NetworkId = targetId }; - autoEvent.RequiredEntity = GameEvent.EventRequiredEntity.Origin | GameEvent.EventRequiredEntity.Target; - autoEvent.GameTime = autoEvent.GameTime; + var anticheatEvent = new AntiCheatDamageEvent + { + ScriptData = eventLine, + Type = GameEvent.EventType.ScriptKill, + Origin = new EFClient { NetworkId = originId }, + Target = new EFClient { NetworkId = targetId }, + RequiredEntity = + GameEvent.EventRequiredEntity.Origin | GameEvent.EventRequiredEntity.Target, + GameTime = autoEvent.GameTime, + IsKill = true + }; - return autoEvent; - } - ); + return anticheatEvent; + } + ); } /// /// this is a custom event printed out by _customcallbacks.gsc (used for anticheat) /// /// - private EventGeneratorCallback ScriptDamage() + public EventGeneratorCallback ScriptDamage() { // this is a custom event printed out by _customcallbacks.gsc (used for anticheat) - return (EVENT_SCRIPTDAMAGE, EVENT_SCRIPTDAMAGE, (string eventLine, IEventParserConfiguration config, GameEvent autoEvent) => - { - string[] lineSplit = eventLine.Split(";"); + return (EventScriptDamage, EventScriptDamage, + (eventLine, config, autoEvent) => + { + var lineSplit = eventLine.Split(";"); - if (lineSplit[1].IsBotGuid() || lineSplit[2].IsBotGuid()) - { - return autoEvent; - } + if (lineSplit[1].IsBotGuid() || lineSplit[2].IsBotGuid()) + { + return autoEvent; + } - long originId = lineSplit[1].ConvertGuidToLong(config.GuidNumberStyle, 1); - long targetId = lineSplit[2].ConvertGuidToLong(config.GuidNumberStyle, 1); + var originId = lineSplit[1].ConvertGuidToLong(config.GuidNumberStyle, 1); + var targetId = lineSplit[2].ConvertGuidToLong(config.GuidNumberStyle, 1); - autoEvent.Type = GameEvent.EventType.ScriptDamage; - autoEvent.Origin = new EFClient() { NetworkId = originId }; - autoEvent.Target = new EFClient() { NetworkId = targetId }; - autoEvent.RequiredEntity = GameEvent.EventRequiredEntity.Origin | GameEvent.EventRequiredEntity.Target; + var anticheatEvent = new AntiCheatDamageEvent + { + ScriptData = eventLine, + Type = GameEvent.EventType.ScriptDamage, + Origin = new EFClient { NetworkId = originId }, + Target = new EFClient { NetworkId = targetId }, + RequiredEntity = + GameEvent.EventRequiredEntity.Origin | GameEvent.EventRequiredEntity.Target, + GameTime = autoEvent.GameTime + }; - return autoEvent; - } - ); - } - - /// - /// this is a custom event printed out by _customcallbacks.gsc (used for anticheat) - /// - /// - private EventGeneratorCallback JoinTeam() - { - // this is a custom event printed out by _customcallbacks.gsc (used for anticheat) - return (EVENT_JOINTEAM, EVENT_JOINTEAM, (string eventLine, IEventParserConfiguration config, GameEvent autoEvent) => - { - string[] lineSplit = eventLine.Split(";"); - - if (lineSplit[1].IsBotGuid() || lineSplit[2].IsBotGuid()) - { - return autoEvent; - } - - long originId = lineSplit[1].ConvertGuidToLong(config.GuidNumberStyle, 1); - long targetId = lineSplit[2].ConvertGuidToLong(config.GuidNumberStyle, 1); - - autoEvent.Type = GameEvent.EventType.JoinTeam; - autoEvent.Origin = new EFClient() { NetworkId = lineSplit[1].ConvertGuidToLong(config.GuidNumberStyle) }; - autoEvent.RequiredEntity = GameEvent.EventRequiredEntity.Target; - - return autoEvent; - } - ); + return anticheatEvent; + } + ); } public IEnumerable Events => new[] { ScriptKill(), - ScriptDamage(), - JoinTeam() + ScriptDamage() }; } + + public class AntiCheatDamageEvent : GameScriptEvent + { + public bool IsKill { get; init; } + } } diff --git a/Plugins/Stats/Helpers/AdvancedClientStatsResourceQueryHelper.cs b/Plugins/Stats/Helpers/AdvancedClientStatsResourceQueryHelper.cs index a1cd006ee..994a3ad3e 100644 --- a/Plugins/Stats/Helpers/AdvancedClientStatsResourceQueryHelper.cs +++ b/Plugins/Stats/Helpers/AdvancedClientStatsResourceQueryHelper.cs @@ -7,7 +7,6 @@ using Data.Models; using Data.Models.Client; using Data.Models.Client.Stats; using IW4MAdmin.Plugins.Stats; -using IW4MAdmin.Plugins.Stats.Config; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using SharedLibraryCore.Dtos; @@ -114,7 +113,7 @@ namespace Stats.Helpers All = hitStats, Servers = _manager.GetServers() .Select(server => new ServerInfo - {Name = server.Hostname, IPAddress = server.IP, Port = server.Port, Game = (Reference.Game)server.GameName}) + {Name = server.Hostname, IPAddress = server.ListenAddress, Port = server.ListenPort, Game = (Reference.Game)server.GameName}) .Where(server => server.Game == clientInfo.GameName) .ToList(), Aggregate = hitStats.FirstOrDefault(hit => diff --git a/Plugins/Stats/Helpers/StatManager.cs b/Plugins/Stats/Helpers/StatManager.cs index ef87be344..e93252cde 100644 --- a/Plugins/Stats/Helpers/StatManager.cs +++ b/Plugins/Stats/Helpers/StatManager.cs @@ -46,7 +46,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers private readonly SemaphoreSlim _addPlayerWaiter = new SemaphoreSlim(1, 1); private readonly IServerDistributionCalculator _serverDistributionCalculator; - public StatManager(ILogger logger, IManager mgr, IDatabaseContextFactory contextFactory, + public StatManager(ILogger logger, IDatabaseContextFactory contextFactory, StatsConfiguration statsConfig, IServerDistributionCalculator serverDistributionCalculator) { @@ -360,13 +360,8 @@ namespace IW4MAdmin.Plugins.Stats.Helpers return finished; } - /// - /// Add a server to the StatManager server pool - /// - /// - public void AddServer(Server sv) + public async Task EnsureServerAdded(IGameServer gameServer, CancellationToken token) { - // insert the server if it does not exist try { if (serverModels == null) @@ -374,76 +369,75 @@ namespace IW4MAdmin.Plugins.Stats.Helpers SetupServerIds(); } - long serverId = GetIdForServer(sv); - EFServer server; + var serverId = GetIdForServer(gameServer as Server); - using var ctx = _contextFactory.CreateContext(enableTracking: false); + await using var ctx = _contextFactory.CreateContext(enableTracking: false); var serverSet = ctx.Set(); // get the server from the database if it exists, otherwise create and insert a new one - server = serverSet.FirstOrDefault(s => s.ServerId == serverId); + var cachedServerModel = await serverSet.FirstOrDefaultAsync(s => s.ServerId == serverId, token); // the server might be using legacy server id - if (server == null) + if (cachedServerModel == null) { - server = serverSet.FirstOrDefault(s => s.EndPoint == sv.ToString()); + cachedServerModel = await serverSet.FirstOrDefaultAsync(s => s.EndPoint == gameServer.Id, token); - if (server != null) + if (cachedServerModel != null) { // this provides a way to identify legacy server entries - server.EndPoint = sv.ToString(); - ctx.Update(server); + cachedServerModel.EndPoint = gameServer.Id; + ctx.Update(cachedServerModel); ctx.SaveChanges(); } } // server has never been added before - if (server == null) + if (cachedServerModel == null) { - server = new EFServer() + cachedServerModel = new EFServer { - Port = sv.Port, - EndPoint = sv.ToString(), + Port = gameServer.ListenPort, + EndPoint = gameServer.Id, ServerId = serverId, - GameName = (Reference.Game?)sv.GameName, - HostName = sv.Hostname + GameName = gameServer.GameCode, + HostName = gameServer.ListenAddress }; - server = serverSet.Add(server).Entity; + cachedServerModel = serverSet.Add(cachedServerModel).Entity; // this doesn't need to be async as it's during initialization - ctx.SaveChanges(); + await ctx.SaveChangesAsync(token); } // 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) + else if (!cachedServerModel.GameName.HasValue || cachedServerModel.GameName.Value != gameServer.GameCode) { - server.GameName = (Reference.Game)sv.GameName; - ctx.Entry(server).Property(_prop => _prop.GameName).IsModified = true; - ctx.SaveChanges(); + cachedServerModel.GameName = gameServer.GameCode; + ctx.Entry(cachedServerModel).Property(property => property.GameName).IsModified = true; + await ctx.SaveChangesAsync(token); } - if (server.HostName == null || server.HostName != sv.Hostname) + if (cachedServerModel.HostName == null || cachedServerModel.HostName != gameServer.ServerName) { - server.HostName = sv.Hostname; - ctx.Entry(server).Property(_prop => _prop.HostName).IsModified = true; - ctx.SaveChanges(); + cachedServerModel.HostName = gameServer.ServerName; + ctx.Entry(cachedServerModel).Property(property => property.HostName).IsModified = true; + await ctx.SaveChangesAsync(token); } - ctx.Entry(server).Property(_prop => _prop.IsPasswordProtected).IsModified = true; - server.IsPasswordProtected = !string.IsNullOrEmpty(sv.GamePassword); - ctx.SaveChanges(); + ctx.Entry(cachedServerModel).Property(property => property.IsPasswordProtected).IsModified = true; + cachedServerModel.IsPasswordProtected = !string.IsNullOrEmpty(gameServer.GamePassword); + await ctx.SaveChangesAsync(token); // check to see if the stats have ever been initialized - var serverStats = InitializeServerStats(server.ServerId); + var serverStats = InitializeServerStats(cachedServerModel.ServerId); - _servers.TryAdd(serverId, new ServerStats(server, serverStats, sv) + _servers.TryAdd(serverId, new ServerStats(cachedServerModel, serverStats, gameServer as Server) { - IsTeamBased = sv.Gametype != "dm" + IsTeamBased = gameServer.Gametype != "dm" }); } - catch (Exception e) + catch (Exception ex) { - _log.LogError(e, "{message}", + _log.LogError(ex, "{Message}", Utilities.CurrentLocalization.LocalizationIndex["PLUGIN_STATS_ERROR_ADD"]); } } @@ -552,7 +546,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers clientStats.SessionScore = pl.Score; clientStats.LastScore = pl.Score; - pl.SetAdditionalProperty(CLIENT_DETECTIONS_KEY, new Detection(_log, clientStats)); + pl.SetAdditionalProperty(CLIENT_DETECTIONS_KEY, new Detection(_log, clientStats, _config)); _log.LogDebug("Added {client} to stats", pl.ToString()); return clientStats; @@ -586,41 +580,42 @@ namespace IW4MAdmin.Plugins.Stats.Helpers /// /// Perform stat updates for disconnecting client /// - /// Disconnecting client + /// Disconnecting client + /// /// - public async Task RemovePlayer(EFClient pl) + public async Task RemovePlayer(EFClient client, CancellationToken cancellationToken) { - _log.LogDebug("Removing {client} from stats", pl.ToString()); + _log.LogDebug("Removing {Client} from stats", client.ToString()); - if (pl.CurrentServer == null) + if (client.CurrentServer == null) { - _log.LogWarning("Disconnecting client {client} is not on a server", pl.ToString()); + _log.LogWarning("Disconnecting client {Client} is not on a server", client.ToString()); return; } - var serverId = GetIdForServer(pl.CurrentServer); + var serverId = GetIdForServer(client.CurrentServer); var serverStats = _servers[serverId].ServerStatistics; // get individual client's stats - var clientStats = pl.GetAdditionalProperty(CLIENT_STATS_KEY); + var clientStats = client.GetAdditionalProperty(CLIENT_STATS_KEY); // sync their stats before they leave if (clientStats != null) { - clientStats = UpdateStats(clientStats, pl); + clientStats = UpdateStats(clientStats, client); await SaveClientStats(clientStats); if (_config.EnableAdvancedMetrics) { - await UpdateHistoricalRanking(pl.ClientId, clientStats, serverId); + await UpdateHistoricalRanking(client.ClientId, clientStats, serverId); } // increment the total play time - serverStats.TotalPlayTime += pl.ConnectionLength; - pl.SetAdditionalProperty(CLIENT_STATS_KEY, null); + serverStats.TotalPlayTime += client.ConnectionLength; + client.SetAdditionalProperty(CLIENT_STATS_KEY, null); } else { - _log.LogWarning("Disconnecting client {client} has not been added to stats", pl.ToString()); + _log.LogWarning("Disconnecting client {Client} has not been added to stats", client.ToString()); } } @@ -743,7 +738,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers return; } - if (Plugin.Config.Configuration().StoreClientKills) + if (_config.StoreClientKills) { var serverWaiter = _servers[serverId].OnSaving; try @@ -772,7 +767,7 @@ namespace IW4MAdmin.Plugins.Stats.Helpers } } - if (Plugin.Config.Configuration().AnticheatConfiguration.Enable && !attacker.IsBot && + if (_config.AnticheatConfiguration.Enable && !attacker.IsBot && attacker.ClientId != victim.ClientId) { clientDetection.TrackedHits.Add(hit); @@ -857,10 +852,10 @@ namespace IW4MAdmin.Plugins.Stats.Helpers private bool ShouldUseDetection(Server server, DetectionType detectionType, long clientId) { #pragma warning disable CS0612 - var serverDetectionTypes = Plugin.Config.Configuration().AnticheatConfiguration.ServerDetectionTypes; + var serverDetectionTypes = _config.AnticheatConfiguration.ServerDetectionTypes; #pragma warning restore CS0612 - var gameDetectionTypes = Plugin.Config.Configuration().AnticheatConfiguration.GameDetectionTypes; - var ignoredClients = Plugin.Config.Configuration().AnticheatConfiguration.IgnoredClientIds; + var gameDetectionTypes = _config.AnticheatConfiguration.GameDetectionTypes; + var ignoredClients = _config.AnticheatConfiguration.IgnoredClientIds; if (ignoredClients.Contains(clientId)) { @@ -1011,9 +1006,9 @@ namespace IW4MAdmin.Plugins.Stats.Helpers victimStats.LastScore = estimatedVictimScore; // show encouragement/discouragement - var streakMessage = (attackerStats.ClientId != victimStats.ClientId) - ? StreakMessage.MessageOnStreak(attackerStats.KillStreak, attackerStats.DeathStreak) - : StreakMessage.MessageOnStreak(-1, -1); + var streakMessage = attackerStats.ClientId != victimStats.ClientId + ? StreakMessage.MessageOnStreak(attackerStats.KillStreak, attackerStats.DeathStreak, _config) + : StreakMessage.MessageOnStreak(-1, -1, _config); if (streakMessage != string.Empty) { @@ -1530,13 +1525,13 @@ namespace IW4MAdmin.Plugins.Stats.Helpers return serverStats; } - public void ResetKillstreaks(Server sv) + public void ResetKillstreaks(IGameServer gameServer) { - foreach (var session in sv.GetClientsAsList() - .Select(_client => new + foreach (var session in gameServer.ConnectedClients + .Select(client => new { - stat = _client.GetAdditionalProperty(CLIENT_STATS_KEY), - detection = _client.GetAdditionalProperty(CLIENT_DETECTIONS_KEY) + stat = client.GetAdditionalProperty(CLIENT_STATS_KEY), + detection = client.GetAdditionalProperty(CLIENT_DETECTIONS_KEY) })) { session.stat?.StartNewSession(); @@ -1563,7 +1558,8 @@ namespace IW4MAdmin.Plugins.Stats.Helpers stats.EloRating = 200; } - public async Task AddMessageAsync(int clientId, long serverId, bool sentIngame, string message) + public async Task AddMessageAsync(int clientId, long serverId, bool sentIngame, string message, + CancellationToken cancellationToken) { // the web users can have no account if (clientId < 1) @@ -1571,8 +1567,8 @@ namespace IW4MAdmin.Plugins.Stats.Helpers return; } - await using var ctx = _contextFactory.CreateContext(enableTracking: false); - ctx.Set().Add(new EFClientMessage() + await using var context = _contextFactory.CreateContext(enableTracking: false); + context.Set().Add(new EFClientMessage() { ClientId = clientId, Message = message, @@ -1581,26 +1577,26 @@ namespace IW4MAdmin.Plugins.Stats.Helpers SentIngame = sentIngame }); - await ctx.SaveChangesAsync(); + await context.SaveChangesAsync(cancellationToken); } - public async Task Sync(Server sv) + public async Task Sync(IGameServer gameServer, CancellationToken token) { - long serverId = GetIdForServer(sv); + var serverId = GetIdForServer(gameServer); var waiter = _servers[serverId].OnSaving; try { - await waiter.WaitAsync(); + await waiter.WaitAsync(token); - await using var ctx = _contextFactory.CreateContext(); - var serverStatsSet = ctx.Set(); + await using var context = _contextFactory.CreateContext(); + var serverStatsSet = context.Set(); serverStatsSet.Update(_servers[serverId].ServerStatistics); - await ctx.SaveChangesAsync(); + await context.SaveChangesAsync(token); - foreach (var stats in sv.GetClientsAsList() - .Select(_client => _client.GetAdditionalProperty(CLIENT_STATS_KEY)) - .Where(_stats => _stats != null)) + foreach (var stats in gameServer.ConnectedClients + .Select(client => client.GetAdditionalProperty(CLIENT_STATS_KEY)) + .Where(stats => stats != null)) { await SaveClientStats(stats); } @@ -1608,9 +1604,9 @@ namespace IW4MAdmin.Plugins.Stats.Helpers await SaveHitCache(serverId); } - catch (Exception e) + catch (Exception ex) { - _log.LogError(e, "There was a problem syncing server stats"); + _log.LogError(ex, "There was a problem syncing server stats"); } finally @@ -1627,28 +1623,24 @@ namespace IW4MAdmin.Plugins.Stats.Helpers _servers[serverId].IsTeamBased = isTeamBased; } - public static long GetIdForServer(Server server) + public static long GetIdForServer(IGameServer gameServer) { - if ($"{server.IP}:{server.Port.ToString()}" == "66.150.121.184:28965") + if (gameServer.Id == "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); + long id = HashCode.Combine(gameServer.ListenAddress, gameServer.ListenPort); 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; +#pragma warning disable CS0618 + var serverId = serverModels.FirstOrDefault(cachedServer => cachedServer.ServerId == gameServer.LegacyEndpoint || +#pragma warning restore CS0618 + cachedServer.EndPoint == gameServer.ToString() || + cachedServer.ServerId == id)?.ServerId; - if (!serverId.HasValue) - { - return id; - } - - return serverId.Value; + return serverId ?? id; } } } diff --git a/Plugins/Stats/Helpers/StreakMessage.cs b/Plugins/Stats/Helpers/StreakMessage.cs index 504c09d4b..be0ca5297 100644 --- a/Plugins/Stats/Helpers/StreakMessage.cs +++ b/Plugins/Stats/Helpers/StreakMessage.cs @@ -1,29 +1,17 @@ -using SharedLibraryCore; -using SharedLibraryCore.Helpers; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; +using System.Linq; +using Stats.Config; -namespace IW4MAdmin.Plugins.Stats.Helpers +namespace IW4MAdmin.Plugins.Stats.Helpers; + +public static class StreakMessage { - public class StreakMessage + public static string MessageOnStreak(int killStreak, int deathStreak, StatsConfiguration config) { - /// - /// Get a message from the configuration encouraging or discouraging clients - /// - /// how many kills the client has without dying - /// how many deaths the client has without getting a kill - /// message to send to the client - public static string MessageOnStreak(int killStreak, int deathStreak) - { - var killstreakMessage = Plugin.Config.Configuration().KillstreakMessages; - var deathstreakMessage = Plugin.Config.Configuration().DeathstreakMessages; + var killstreakMessage = config.KillstreakMessages; + var deathstreakMessage = config.DeathstreakMessages; - string message = killstreakMessage?.FirstOrDefault(m => m.Count == killStreak)?.Message; - message = message ?? deathstreakMessage?.FirstOrDefault(m => m.Count == deathStreak)?.Message; - return message ?? ""; - } + var message = killstreakMessage?.FirstOrDefault(m => m.Count == killStreak)?.Message; + message ??= deathstreakMessage?.FirstOrDefault(m => m.Count == deathStreak)?.Message; + return message ?? ""; } } diff --git a/Plugins/Stats/Plugin.cs b/Plugins/Stats/Plugin.cs index bd9eed342..6be93505c 100644 --- a/Plugins/Stats/Plugin.cs +++ b/Plugins/Stats/Plugin.cs @@ -12,489 +12,570 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; using Data.Abstractions; +using Data.Models; using Data.Models.Client.Stats; using Data.Models.Server; using Microsoft.Extensions.Logging; using IW4MAdmin.Plugins.Stats.Client.Abstractions; +using IW4MAdmin.Plugins.Stats.Events; +using Microsoft.Extensions.DependencyInjection; +using SharedLibraryCore.Events.Game; +using SharedLibraryCore.Events.Management; +using SharedLibraryCore.Interfaces.Events; using Stats.Client.Abstractions; using Stats.Config; using EFClient = SharedLibraryCore.Database.Models.EFClient; -namespace IW4MAdmin.Plugins.Stats +namespace IW4MAdmin.Plugins.Stats; + +public class Plugin : IPluginV2 { - public class Plugin : IPlugin + public string Name => "Simple Stats"; + public string Version => Utilities.GetVersionAsString(); + public string Author => "RaidMax"; + + public static IManager ServerManager; + + private readonly IDatabaseContextFactory _databaseContextFactory; + private readonly ITranslationLookup _translationLookup; + private readonly IMetaServiceV2 _metaService; + private readonly IResourceQueryHelper _chatQueryHelper; + private readonly ILogger _logger; + private readonly List _statCalculators; + private readonly IServerDistributionCalculator _serverDistributionCalculator; + private readonly IServerDataViewer _serverDataViewer; + private readonly StatsConfiguration _statsConfig; + private readonly StatManager _statManager; + + public static void RegisterDependencies(IServiceCollection serviceCollection) { - public string Name => "Simple Stats"; + serviceCollection.AddConfiguration("StatsPluginSettings"); + serviceCollection.AddSingleton(); + } - public float Version => (float)Utilities.GetVersionAsDouble(); + public Plugin(ILogger logger, IDatabaseContextFactory databaseContextFactory, + ITranslationLookup translationLookup, IMetaServiceV2 metaService, + IResourceQueryHelper chatQueryHelper, + IEnumerable statCalculators, + IServerDistributionCalculator serverDistributionCalculator, IServerDataViewer serverDataViewer, + StatsConfiguration statsConfig, StatManager statManager) + { + _databaseContextFactory = databaseContextFactory; + _translationLookup = translationLookup; + _metaService = metaService; + _chatQueryHelper = chatQueryHelper; + _logger = logger; + _statCalculators = statCalculators.ToList(); + _serverDistributionCalculator = serverDistributionCalculator; + _serverDataViewer = serverDataViewer; + _statsConfig = statsConfig; + _statManager = statManager; - public string Author => "RaidMax"; - - public static StatManager Manager { get; private set; } - public static IManager ServerManager; - public static IConfigurationHandler Config { get; private set; } - - private readonly IDatabaseContextFactory _databaseContextFactory; - private readonly ITranslationLookup _translationLookup; - private readonly IMetaServiceV2 _metaService; - private readonly IResourceQueryHelper _chatQueryHelper; - private readonly ILogger _managerLogger; - private readonly ILogger _logger; - private readonly List _statCalculators; - private readonly IServerDistributionCalculator _serverDistributionCalculator; - private readonly IServerDataViewer _serverDataViewer; - - public Plugin(ILogger logger, IConfigurationHandlerFactory configurationHandlerFactory, IDatabaseContextFactory databaseContextFactory, - ITranslationLookup translationLookup, IMetaServiceV2 metaService, IResourceQueryHelper chatQueryHelper, ILogger managerLogger, - IEnumerable statCalculators, IServerDistributionCalculator serverDistributionCalculator, IServerDataViewer serverDataViewer) + IGameServerEventSubscriptions.MonitoringStarted += + async (monitorEvent, token) => await _statManager.EnsureServerAdded(monitorEvent.Server, token); + IGameServerEventSubscriptions.MonitoringStopped += + async (monitorEvent, token) => await _statManager.Sync(monitorEvent.Server, token); + IManagementEventSubscriptions.ClientStateInitialized += async (clientEvent, token) => { - Config = configurationHandlerFactory.GetConfigurationHandler("StatsPluginSettings"); - _databaseContextFactory = databaseContextFactory; - _translationLookup = translationLookup; - _metaService = metaService; - _chatQueryHelper = chatQueryHelper; - _managerLogger = managerLogger; - _logger = logger; - _statCalculators = statCalculators.ToList(); - _serverDistributionCalculator = serverDistributionCalculator; - _serverDataViewer = serverDataViewer; - } - - public async Task OnEventAsync(GameEvent gameEvent, Server server) - { - switch (gameEvent.Type) - { - case GameEvent.EventType.Start: - Manager.AddServer(server); - break; - case GameEvent.EventType.Disconnect: - await Manager.RemovePlayer(gameEvent.Origin); - break; - case GameEvent.EventType.Say: - if (!string.IsNullOrEmpty(gameEvent.Data) && - gameEvent.Origin.ClientId > 1) - { - await Manager.AddMessageAsync(gameEvent.Origin.ClientId, StatManager.GetIdForServer(server), true, gameEvent.Data); - } - break; - case GameEvent.EventType.MapChange: - Manager.SetTeamBased(StatManager.GetIdForServer(server), server.Gametype != "dm"); - Manager.ResetKillstreaks(server); - await Manager.Sync(server); - break; - case GameEvent.EventType.MapEnd: - Manager.ResetKillstreaks(server); - await Manager.Sync(server); - break; - case GameEvent.EventType.Command: - var shouldPersist = !string.IsNullOrEmpty(gameEvent.Data) && - gameEvent.Extra?.GetType().Name == "SayCommand"; - if (shouldPersist) - { - await Manager.AddMessageAsync(gameEvent.Origin.ClientId, StatManager.GetIdForServer(server), false, gameEvent.Data); - } - break; - case GameEvent.EventType.ScriptKill: - var killInfo = (gameEvent.Data != null) ? gameEvent.Data.Split(';') : Array.Empty(); - if ((server.CustomCallback || ShouldOverrideAnticheatSetting(server)) && killInfo.Length >= 18 && !ShouldIgnoreEvent(gameEvent.Origin, gameEvent.Target)) - { - // this treats "world" damage as self damage - if (IsWorldDamage(gameEvent.Origin)) - { - gameEvent.Origin = gameEvent.Target; - } - - await EnsureClientsAdded(gameEvent.Origin, gameEvent.Target); - await Manager.AddScriptHit(false, gameEvent.Time, gameEvent.Origin, gameEvent.Target, StatManager.GetIdForServer(server), server.CurrentMap.Name, killInfo[7], killInfo[8], - killInfo[5], killInfo[6], killInfo[3], killInfo[4], killInfo[9], killInfo[10], killInfo[11], killInfo[12], killInfo[13], killInfo[14], killInfo[15], killInfo[16], killInfo[17]); - } - - else - { - _logger.LogDebug("Skipping script kill as it is ignored or data in customcallbacks is outdated/missing"); - } - break; - case GameEvent.EventType.Kill: - if (!ShouldIgnoreEvent(gameEvent.Origin, gameEvent.Target)) - { - // this treats "world" damage as self damage - if (IsWorldDamage(gameEvent.Origin)) - { - gameEvent.Origin = gameEvent.Target; - } - - await EnsureClientsAdded(gameEvent.Origin, gameEvent.Target); - await Manager.AddStandardKill(gameEvent.Origin, gameEvent.Target); - } - break; - case GameEvent.EventType.Damage: - if (!ShouldIgnoreEvent(gameEvent.Origin, gameEvent.Target)) - { - // this treats "world" damage as self damage - if (IsWorldDamage(gameEvent.Origin)) - { - gameEvent.Origin = gameEvent.Target; - } - - Manager.AddDamageEvent(gameEvent.Data, gameEvent.Origin.ClientId, gameEvent.Target.ClientId, StatManager.GetIdForServer(server)); - } - break; - case GameEvent.EventType.ScriptDamage: - killInfo = (gameEvent.Data != null) ? gameEvent.Data.Split(';') : new string[0]; - if ((server.CustomCallback || ShouldOverrideAnticheatSetting(server)) && killInfo.Length >= 18 && !ShouldIgnoreEvent(gameEvent.Origin, gameEvent.Target)) - { - // this treats "world" damage as self damage - if (IsWorldDamage(gameEvent.Origin)) - { - gameEvent.Origin = gameEvent.Target; - } - - await EnsureClientsAdded(gameEvent.Origin, gameEvent.Target); - await Manager.AddScriptHit(true, gameEvent.Time, gameEvent.Origin, gameEvent.Target, StatManager.GetIdForServer(server), server.CurrentMap.Name, killInfo[7], killInfo[8], - killInfo[5], killInfo[6], killInfo[3], killInfo[4], killInfo[9], killInfo[10], killInfo[11], killInfo[12], killInfo[13], killInfo[14], killInfo[15], killInfo[16], killInfo[17]); - } - - else - { - _logger.LogDebug("Skipping script damage as it is ignored or data in customcallbacks is outdated/missing"); - } - break; - } - - if (!Config.Configuration().EnableAdvancedMetrics) + if (!_statsConfig.EnableAdvancedMetrics) { return; } - + foreach (var calculator in _statCalculators) { - await calculator.CalculateForEvent(gameEvent); + await calculator.CalculateForEvent(clientEvent); } - } - - public async Task OnLoadAsync(IManager manager) - { - await Config.BuildAsync(); - // load custom configuration - if (Config.Configuration() == null) + }; + IManagementEventSubscriptions.ClientStateDisposed += + async (clientEvent, token) => { - Config.Set((StatsConfiguration)new StatsConfiguration().Generate()); - } - Config.Configuration().ApplyMigration(); - await Config.Save(); + await _statManager.RemovePlayer(clientEvent.Client, token); - // register the topstats page - // todo:generate the URL/Location instead of hardcoding - manager.GetPageList() - .Pages.Add( - Utilities.CurrentLocalization.LocalizationIndex["PLUGINS_STATS_COMMANDS_TOP_TEXT"], - "/Stats/TopPlayers"); - - // meta data info - async Task> GetStats(ClientPaginationRequest request, CancellationToken token = default) - { - await using var ctx = _databaseContextFactory.CreateContext(enableTracking: false); - IList clientStats = await ctx.Set().Where(c => c.ClientId == request.ClientId).ToListAsync(token); - - var kills = clientStats.Sum(c => c.Kills); - var deaths = clientStats.Sum(c => c.Deaths); - var kdr = Math.Round(kills / (double)deaths, 2); - var validPerformanceValues = clientStats.Where(c => c.Performance > 0).ToList(); - var performancePlayTime = validPerformanceValues.Sum(s => s.TimePlayed); - var performance = Math.Round(validPerformanceValues.Sum(c => c.Performance * c.TimePlayed / performancePlayTime), 2); - var spm = Math.Round(clientStats.Sum(c => c.SPM) / clientStats.Count(c => c.SPM > 0), 1); - var overallRanking = await Manager.GetClientOverallRanking(request.ClientId); - - return new List + if (!_statsConfig.EnableAdvancedMetrics) { - new InformationResponse - { - Key = Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_META_RANKING"], - Value = Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_META_RANKING_FORMAT"].FormatExt((overallRanking == 0 ? "--" : - overallRanking.ToString("#,##0", new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName))), - (await _serverDataViewer.RankedClientsCountAsync(token: token)).ToString("#,##0", new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)) - ), - Column = 0, - Order = 0, - Type = MetaType.Information - }, - new InformationResponse - { - Key = Utilities.CurrentLocalization.LocalizationIndex["PLUGINS_STATS_TEXT_KILLS"], - Value = kills.ToString("#,##0", new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)), - Column = 0, - Order = 1, - Type = MetaType.Information - }, - new InformationResponse - { - Key = Utilities.CurrentLocalization.LocalizationIndex["PLUGINS_STATS_TEXT_DEATHS"], - Value = deaths.ToString("#,##0", new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)), - Column = 0, - Order = 2, - Type = MetaType.Information - }, - new InformationResponse - { - Key = Utilities.CurrentLocalization.LocalizationIndex["PLUGINS_STATS_TEXT_KDR"], - Value = kdr.ToString(new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)), - Column = 0, - Order = 3, - Type = MetaType.Information - }, - new InformationResponse - { - Key = Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_PROFILE_PERFORMANCE"], - Value = performance.ToString("#,##0", new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)), - Column = 0, - Order = 4, - Type = MetaType.Information - }, - new InformationResponse - { - Key = Utilities.CurrentLocalization.LocalizationIndex["PLUGINS_STATS_META_SPM"], - Value = spm.ToString(new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)), - Column = 0, - Order = 5, - Type = MetaType.Information - } - }; - } - - async Task> GetAnticheatInfo(ClientPaginationRequest request, CancellationToken token = default) - { - await using var context = _databaseContextFactory.CreateContext(enableTracking: false); - IList clientStats = await context.Set() - .Include(c => c.HitLocations) - .Where(c => c.ClientId == request.ClientId) - .ToListAsync(token); - - double headRatio = 0; - double chestRatio = 0; - double abdomenRatio = 0; - double chestAbdomenRatio = 0; - double hitOffsetAverage = 0; - double averageSnapValue = 0; - var maxStrain = !clientStats.Any(c => c.MaxStrain > 0) ? 0 : clientStats.Max(cs => cs.MaxStrain); - - if (clientStats.Any(cs => cs.HitLocations.Count > 0)) - { - chestRatio = Math.Round((clientStats.Where(c => c.HitLocations.Count > 0).Sum(c => - c.HitLocations.First(hl => hl.Location == (int)IW4Info.HitLocation.torso_upper).HitCount) / - (double)clientStats.Where(c => c.HitLocations.Count > 0) - .Sum(c => c.HitLocations.Where(hl => hl.Location != (int)IW4Info.HitLocation.none).Sum(f => f.HitCount))) * 100.0, 0); - - abdomenRatio = Math.Round((clientStats.Where(c => c.HitLocations.Count > 0).Sum(c => - c.HitLocations.First(hl => hl.Location == (int)IW4Info.HitLocation.torso_lower).HitCount) / - (double)clientStats.Where(c => c.HitLocations.Count > 0).Sum(c => c.HitLocations.Where(hl => hl.Location != (int)IW4Info.HitLocation.none).Sum(f => f.HitCount))) * 100.0, 0); - - chestAbdomenRatio = Math.Round((clientStats.Where(c => c.HitLocations.Count > 0).Sum(cs => cs.HitLocations.First(hl => hl.Location == (int)IW4Info.HitLocation.torso_upper).HitCount) / - (double)clientStats.Where(c => c.HitLocations.Count > 0).Sum(cs => cs.HitLocations.First(hl => hl.Location == (int)IW4Info.HitLocation.torso_lower).HitCount)) * 100.0, 0); - - headRatio = Math.Round((clientStats.Where(c => c.HitLocations.Count > 0).Sum(cs => cs.HitLocations.First(hl => hl.Location == (int)IW4Info.HitLocation.head).HitCount) / - (double)clientStats.Where(c => c.HitLocations.Count > 0) - .Sum(c => c.HitLocations.Where(hl => hl.Location != (int)IW4Info.HitLocation.none).Sum(f => f.HitCount))) * 100.0, 0); - - var validOffsets = clientStats.Where(c => c.HitLocations.Count(hl => hl.HitCount > 0) > 0).SelectMany(hl => hl.HitLocations).ToList(); - hitOffsetAverage = validOffsets.Sum(o => o.HitCount * o.HitOffsetAverage) / (double)validOffsets.Sum(o => o.HitCount); - averageSnapValue = clientStats.Any(_stats => _stats.AverageSnapValue > 0) ? clientStats.Where(_stats => _stats.AverageSnapValue > 0).Average(_stat => _stat.AverageSnapValue) : 0; + return; } - return new List - { - new InformationResponse() - { - Key = $"{Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_META_AC_METRIC"]} 1", - Value = chestRatio.ToString(new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)) + '%', - Type = MetaType.Information, - Order = 100, - ToolTipText = Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_TITLE_ACM1"], - IsSensitive = true - }, - new InformationResponse() - { - Key = $"{Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_META_AC_METRIC"]} 2", - Value = abdomenRatio.ToString(new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)) + '%', - Type = MetaType.Information, - Order = 101, - ToolTipText = Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_TITLE_ACM2"], - IsSensitive = true - }, - new InformationResponse() - { - Key = $"{Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_META_AC_METRIC"]} 3", - Value = chestAbdomenRatio.ToString(new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)) + '%', - Type = MetaType.Information, - Order = 102, - ToolTipText = Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_TITLE_ACM3"], - IsSensitive = true - }, - new InformationResponse() - { - Key = $"{Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_META_AC_METRIC"]} 4", - Value = headRatio.ToString(new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)) + '%', - Type = MetaType.Information, - Order = 103, - ToolTipText = Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_TITLE_ACM4"], - IsSensitive = true - }, - new InformationResponse() - { - Key = $"{Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_META_AC_METRIC"]} 5", - // todo: make sure this is wrapped somewhere else - Value = $"{Math.Round(((float)hitOffsetAverage), 4).ToString(new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName))}°", - Type = MetaType.Information, - Order = 104, - ToolTipText = Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_TITLE_ACM5"], - IsSensitive = true - }, - new InformationResponse() - { - Key = $"{Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_META_AC_METRIC"]} 6", - Value = Math.Round(maxStrain, 3).ToString(new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)), - Type = MetaType.Information, - Order = 105, - ToolTipText = Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_TITLE_ACM6"], - IsSensitive = true - }, - new InformationResponse() - { - Key = $"{Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_META_AC_METRIC"]} 7", - Value = Math.Round(averageSnapValue, 3).ToString(new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)), - Type = MetaType.Information, - Order = 106, - ToolTipText = Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_TITLE_ACM7"], - IsSensitive = true - } - }; - } - - async Task> GetMessages(ClientPaginationRequest request, CancellationToken token = default) - { - var query = new ChatSearchQuery - { - ClientId = request.ClientId, - Before = request.Before, - SentBefore = request.Before ?? DateTime.UtcNow, - Count = request.Count, - IsProfileMeta = true - }; - - return (await _chatQueryHelper.QueryResource(query)).Results; - } - - if (Config.Configuration().AnticheatConfiguration.Enable) - { - _metaService.AddRuntimeMeta(MetaType.Information, GetAnticheatInfo); - } - - _metaService.AddRuntimeMeta(MetaType.Information, GetStats); - _metaService.AddRuntimeMeta(MetaType.ChatMessage, GetMessages); - - async Task TotalKills(Server server) - { - await using var context = _databaseContextFactory.CreateContext(false); - var kills = await context.Set().Where(s => s.Active).SumAsync(s => s.TotalKills); - return kills.ToString("#,##0", new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)); - } - - async Task TotalPlayTime(Server server) - { - await using var context = _databaseContextFactory.CreateContext(false); - var playTime = await context.Set().Where(s => s.Active).SumAsync(s => s.TotalPlayTime); - return (playTime / 3600.0).ToString("#,##0", new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)); - } - - async Task TopStats(Server s) - { - // todo: this needs to needs to be updated when we DI the lookup - return string.Join(Environment.NewLine, await Commands.TopStats.GetTopStats(s, Utilities.CurrentLocalization.LocalizationIndex)); - } - - async Task MostPlayed(Server s) - { - // todo: this needs to needs to be updated when we DI the lookup - return string.Join(Environment.NewLine, await Commands.MostPlayedCommand.GetMostPlayed(s, Utilities.CurrentLocalization.LocalizationIndex, _databaseContextFactory)); - } - - async Task MostKills(Server gameServer) - { - return string.Join(Environment.NewLine, - await Commands.MostKillsCommand.GetMostKills(StatManager.GetIdForServer(gameServer), Config.Configuration(), _databaseContextFactory, _translationLookup)); - } - - manager.GetMessageTokens().Add(new MessageToken("TOTALKILLS", TotalKills)); - manager.GetMessageTokens().Add(new MessageToken("TOTALPLAYTIME", TotalPlayTime)); - manager.GetMessageTokens().Add(new MessageToken("TOPSTATS", TopStats)); - manager.GetMessageTokens().Add(new MessageToken("MOSTPLAYED", MostPlayed)); - manager.GetMessageTokens().Add(new MessageToken("MOSTKILLS", MostKills)); - - if (Config.Configuration().EnableAdvancedMetrics) - { foreach (var calculator in _statCalculators) { - await calculator.GatherDependencies(); + await calculator.CalculateForEvent(clientEvent); } + }; + IGameEventSubscriptions.ClientMessaged += async (messageEvent, token) => + { + if (!string.IsNullOrEmpty(messageEvent.Message) && + messageEvent.Client.ClientId > 1) + { + await _statManager.AddMessageAsync(messageEvent.Client.ClientId, + StatManager.GetIdForServer(messageEvent.Server), true, messageEvent.Message, token); + } + }; + IGameEventSubscriptions.MatchEnded += OnMatchEvent; + IGameEventSubscriptions.MatchStarted += OnMatchEvent; + IGameEventSubscriptions.ScriptEventTriggered += OnScriptEvent; + IGameEventSubscriptions.ClientKilled += OnClientKilled; + IGameEventSubscriptions.ClientDamaged += OnClientDamaged; + IManagementEventSubscriptions.ClientCommandExecuted += OnClientCommandExecute; + IManagementEventSubscriptions.Load += OnLoad; + } + + private async Task OnClientKilled(ClientKillEvent killEvent, CancellationToken token) + { + if (!ShouldIgnoreEvent(killEvent.Attacker, killEvent.Victim)) + { + // this treats "world" damage as self damage + if (IsWorldDamage(killEvent.Attacker)) + { + killEvent.UpdateAttacker(killEvent.Victim); } - ServerManager = manager; - Manager = new StatManager(_managerLogger, manager, _databaseContextFactory, Config.Configuration(), _serverDistributionCalculator); - await _serverDistributionCalculator.Initialize(); - } + await EnsureClientsAdded(killEvent.Attacker, killEvent.Victim); + await _statManager.AddStandardKill(killEvent.Attacker, killEvent.Victim); - public Task OnTickAsync(Server server) - { - return Task.CompletedTask; - } - - public async Task OnUnloadAsync() - { - foreach (var sv in ServerManager.GetServers()) + if (!_statsConfig.EnableAdvancedMetrics) { - await Manager.Sync(sv); + return; } - } - /// - /// Indicates if the event should be ignored - /// (If the client id or target id is not a real client or the target/origin is a bot and ignore bots is turned on) - /// - /// - /// - /// - private bool ShouldIgnoreEvent(EFClient origin, EFClient target) - { - return ((origin?.NetworkId == Utilities.WORLD_ID && target?.NetworkId == Utilities.WORLD_ID)); - } - - /// - /// Indicates if the damage occurs from world (fall damage/certain killstreaks) - /// - /// - /// - private bool IsWorldDamage(EFClient origin) => origin?.NetworkId == Utilities.WORLD_ID || origin?.ClientId == Utilities.WORLD_ID; - - /// - /// Indicates if we should try to use anticheat even if sv_customcallbacks is not defined - /// - /// - /// - private bool ShouldOverrideAnticheatSetting(Server s) => Config.Configuration().AnticheatConfiguration.Enable && s.GameName == Server.Game.IW5; - - /// - /// Makes sure both clients are added - /// - /// - /// - /// - private async Task EnsureClientsAdded(EFClient origin, EFClient target) - { - await Manager.AddPlayer(origin); - - if (!origin.Equals(target)) + foreach (var calculator in _statCalculators) { - await Manager.AddPlayer(target); + await calculator.CalculateForEvent(killEvent); } } } + + private async Task OnClientDamaged(ClientDamageEvent damageEvent, CancellationToken token) + { + if (ShouldIgnoreEvent(damageEvent.Attacker, damageEvent.Victim)) + { + return; + } + + if (!_statsConfig.EnableAdvancedMetrics) + { + return; + } + + // this treats "world" damage as self damage + if (IsWorldDamage(damageEvent.Attacker)) + { + damageEvent.UpdateAttacker(damageEvent.Victim); + } + + foreach (var calculator in _statCalculators) + { + await calculator.CalculateForEvent(damageEvent); + } + } + + private async Task OnScriptEvent(GameScriptEvent scriptEvent, CancellationToken token) + { + if (scriptEvent is not AntiCheatDamageEvent antiCheatDamageEvent) + { + return; + } + + var killInfo = scriptEvent.ScriptData?.Split(';') ?? Array.Empty(); + if ((scriptEvent.Server.IsLegacyGameIntegrationEnabled || + ShouldOverrideAnticheatSetting(scriptEvent.Server)) && killInfo.Length >= 18 && + !ShouldIgnoreEvent(antiCheatDamageEvent.Origin, antiCheatDamageEvent.Target)) + { + // this treats "world" damage as self damage + if (IsWorldDamage(antiCheatDamageEvent.Origin)) + { + antiCheatDamageEvent.Origin = antiCheatDamageEvent.Target; + } + + await EnsureClientsAdded(antiCheatDamageEvent.Origin, antiCheatDamageEvent.Target); + await _statManager.AddScriptHit(!antiCheatDamageEvent.IsKill, antiCheatDamageEvent.CreatedAt.DateTime, + antiCheatDamageEvent.Origin, + antiCheatDamageEvent.Target, + StatManager.GetIdForServer(antiCheatDamageEvent.Server), antiCheatDamageEvent.Server.Map.Name, + killInfo[7], killInfo[8], + killInfo[5], killInfo[6], killInfo[3], killInfo[4], killInfo[9], killInfo[10], killInfo[11], + killInfo[12], killInfo[13], killInfo[14], killInfo[15], killInfo[16], killInfo[17]); + } + } + + private async Task OnClientCommandExecute(ClientExecuteCommandEvent commandEvent, CancellationToken token) + { + var shouldPersist = !string.IsNullOrEmpty(commandEvent.CommandText) && commandEvent.Command.Name == "say"; + + if (shouldPersist) + { + await _statManager.AddMessageAsync(commandEvent.Client.ClientId, + StatManager.GetIdForServer(commandEvent.Client.CurrentServer), false, commandEvent.CommandText, token); + } + } + + private async Task OnMatchEvent(GameEventV2 gameEvent, CancellationToken token) + { + _statManager.SetTeamBased(StatManager.GetIdForServer(gameEvent.Server), gameEvent.Server.Gametype != "dm"); + _statManager.ResetKillstreaks(gameEvent.Server); + await _statManager.Sync(gameEvent.Server, token); + + if (!_statsConfig.EnableAdvancedMetrics) + { + return; + } + + foreach (var calculator in _statCalculators) + { + await calculator.CalculateForEvent(gameEvent); + } + } + + private async Task OnLoad(IManager manager, CancellationToken token) + { + // register the topstats page + // todo:generate the URL/Location instead of hardcoding + manager.GetPageList() + .Pages.Add( + Utilities.CurrentLocalization.LocalizationIndex["PLUGINS_STATS_COMMANDS_TOP_TEXT"], + "/Stats/TopPlayers"); + + // meta data info + async Task> GetStats(ClientPaginationRequest request, + CancellationToken token = default) + { + await using var ctx = _databaseContextFactory.CreateContext(enableTracking: false); + IList clientStats = await ctx.Set() + .Where(c => c.ClientId == request.ClientId).ToListAsync(token); + + var kills = clientStats.Sum(c => c.Kills); + var deaths = clientStats.Sum(c => c.Deaths); + var kdr = Math.Round(kills / (double)deaths, 2); + var validPerformanceValues = clientStats.Where(c => c.Performance > 0).ToList(); + var performancePlayTime = validPerformanceValues.Sum(s => s.TimePlayed); + var performance = + Math.Round(validPerformanceValues.Sum(c => c.Performance * c.TimePlayed / performancePlayTime), 2); + var spm = Math.Round(clientStats.Sum(c => c.SPM) / clientStats.Count(c => c.SPM > 0), 1); + var overallRanking = await _statManager.GetClientOverallRanking(request.ClientId); + + return new List + { + new InformationResponse + { + Key = Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_META_RANKING"], + Value = Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_META_RANKING_FORMAT"] + .FormatExt( + (overallRanking == 0 + ? "--" + : overallRanking.ToString("#,##0", + new System.Globalization.CultureInfo(Utilities.CurrentLocalization + .LocalizationName))), + (await _serverDataViewer.RankedClientsCountAsync(token: token)).ToString("#,##0", + new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)) + ), + Column = 0, + Order = 0, + Type = MetaType.Information + }, + new InformationResponse + { + Key = Utilities.CurrentLocalization.LocalizationIndex["PLUGINS_STATS_TEXT_KILLS"], + Value = kills.ToString("#,##0", + new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)), + Column = 0, + Order = 1, + Type = MetaType.Information + }, + new InformationResponse + { + Key = Utilities.CurrentLocalization.LocalizationIndex["PLUGINS_STATS_TEXT_DEATHS"], + Value = deaths.ToString("#,##0", + new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)), + Column = 0, + Order = 2, + Type = MetaType.Information + }, + new InformationResponse + { + Key = Utilities.CurrentLocalization.LocalizationIndex["PLUGINS_STATS_TEXT_KDR"], + Value = kdr.ToString( + new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)), + Column = 0, + Order = 3, + Type = MetaType.Information + }, + new InformationResponse + { + Key = Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_PROFILE_PERFORMANCE"], + Value = performance.ToString("#,##0", + new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)), + Column = 0, + Order = 4, + Type = MetaType.Information + }, + new InformationResponse + { + Key = Utilities.CurrentLocalization.LocalizationIndex["PLUGINS_STATS_META_SPM"], + Value = spm.ToString( + new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)), + Column = 0, + Order = 5, + Type = MetaType.Information + } + }; + } + + async Task> GetAnticheatInfo(ClientPaginationRequest request, + CancellationToken token = default) + { + await using var context = _databaseContextFactory.CreateContext(enableTracking: false); + IList clientStats = await context.Set() + .Include(c => c.HitLocations) + .Where(c => c.ClientId == request.ClientId) + .ToListAsync(token); + + double headRatio = 0; + double chestRatio = 0; + double abdomenRatio = 0; + double chestAbdomenRatio = 0; + double hitOffsetAverage = 0; + double averageSnapValue = 0; + var maxStrain = !clientStats.Any(c => c.MaxStrain > 0) ? 0 : clientStats.Max(cs => cs.MaxStrain); + + if (clientStats.Any(cs => cs.HitLocations.Count > 0)) + { + chestRatio = Math.Round((clientStats.Where(c => c.HitLocations.Count > 0).Sum(c => + c.HitLocations.First(hl => + hl.Location == (int)IW4Info.HitLocation.torso_upper).HitCount) / + (double)clientStats.Where(c => c.HitLocations.Count > 0) + .Sum(c => c.HitLocations + .Where(hl => hl.Location != (int)IW4Info.HitLocation.none) + .Sum(f => f.HitCount))) * 100.0, 0); + + abdomenRatio = Math.Round((clientStats.Where(c => c.HitLocations.Count > 0).Sum(c => + c.HitLocations.First(hl => + hl.Location == (int)IW4Info.HitLocation.torso_lower).HitCount) / + (double)clientStats.Where(c => c.HitLocations.Count > 0).Sum(c => + c.HitLocations.Where(hl => hl.Location != (int)IW4Info.HitLocation.none) + .Sum(f => f.HitCount))) * 100.0, 0); + + chestAbdomenRatio = Math.Round((clientStats.Where(c => c.HitLocations.Count > 0).Sum(cs => + cs.HitLocations.First(hl => + hl.Location == (int)IW4Info.HitLocation.torso_upper).HitCount) / + (double)clientStats.Where(c => c.HitLocations.Count > 0).Sum(cs => + cs.HitLocations.First(hl => + hl.Location == (int)IW4Info.HitLocation.torso_lower) + .HitCount)) * 100.0, 0); + + headRatio = Math.Round((clientStats.Where(c => c.HitLocations.Count > 0).Sum(cs => + cs.HitLocations.First(hl => hl.Location == (int)IW4Info.HitLocation.head) + .HitCount) / + (double)clientStats.Where(c => c.HitLocations.Count > 0) + .Sum(c => c.HitLocations + .Where(hl => hl.Location != (int)IW4Info.HitLocation.none) + .Sum(f => f.HitCount))) * 100.0, 0); + + var validOffsets = clientStats.Where(c => c.HitLocations.Count(hl => hl.HitCount > 0) > 0) + .SelectMany(hl => hl.HitLocations).ToList(); + hitOffsetAverage = validOffsets.Sum(o => o.HitCount * o.HitOffsetAverage) / + (double)validOffsets.Sum(o => o.HitCount); + averageSnapValue = clientStats.Any(_stats => _stats.AverageSnapValue > 0) + ? clientStats.Where(_stats => _stats.AverageSnapValue > 0).Average(_stat => _stat.AverageSnapValue) + : 0; + } + + return new List + { + new InformationResponse() + { + Key = $"{Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_META_AC_METRIC"]} 1", + Value = chestRatio.ToString( + new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)) + '%', + Type = MetaType.Information, + Order = 100, + ToolTipText = Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_TITLE_ACM1"], + IsSensitive = true + }, + new InformationResponse() + { + Key = $"{Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_META_AC_METRIC"]} 2", + Value = abdomenRatio.ToString( + new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)) + '%', + Type = MetaType.Information, + Order = 101, + ToolTipText = Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_TITLE_ACM2"], + IsSensitive = true + }, + new InformationResponse() + { + Key = $"{Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_META_AC_METRIC"]} 3", + Value = chestAbdomenRatio.ToString( + new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)) + '%', + Type = MetaType.Information, + Order = 102, + ToolTipText = Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_TITLE_ACM3"], + IsSensitive = true + }, + new InformationResponse() + { + Key = $"{Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_META_AC_METRIC"]} 4", + Value = headRatio.ToString( + new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)) + '%', + Type = MetaType.Information, + Order = 103, + ToolTipText = Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_TITLE_ACM4"], + IsSensitive = true + }, + new InformationResponse() + { + Key = $"{Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_META_AC_METRIC"]} 5", + // todo: make sure this is wrapped somewhere else + Value = + $"{Math.Round(((float)hitOffsetAverage), 4).ToString(new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName))}°", + Type = MetaType.Information, + Order = 104, + ToolTipText = Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_TITLE_ACM5"], + IsSensitive = true + }, + new InformationResponse() + { + Key = $"{Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_META_AC_METRIC"]} 6", + Value = Math.Round(maxStrain, 3) + .ToString(new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)), + Type = MetaType.Information, + Order = 105, + ToolTipText = Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_TITLE_ACM6"], + IsSensitive = true + }, + new InformationResponse() + { + Key = $"{Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_META_AC_METRIC"]} 7", + Value = Math.Round(averageSnapValue, 3) + .ToString(new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)), + Type = MetaType.Information, + Order = 106, + ToolTipText = Utilities.CurrentLocalization.LocalizationIndex["WEBFRONT_CLIENT_TITLE_ACM7"], + IsSensitive = true + } + }; + } + + async Task> GetMessages(ClientPaginationRequest request, + CancellationToken token = default) + { + var query = new ChatSearchQuery + { + ClientId = request.ClientId, + Before = request.Before, + SentBefore = request.Before ?? DateTime.UtcNow, + Count = request.Count, + IsProfileMeta = true + }; + + return (await _chatQueryHelper.QueryResource(query)).Results; + } + + if (_statsConfig.AnticheatConfiguration.Enable) + { + _metaService.AddRuntimeMeta(MetaType.Information, + GetAnticheatInfo); + } + + _metaService.AddRuntimeMeta(MetaType.Information, GetStats); + _metaService.AddRuntimeMeta(MetaType.ChatMessage, GetMessages); + + async Task TotalKills(Server server) + { + await using var context = _databaseContextFactory.CreateContext(false); + var kills = await context.Set().Where(s => s.Active).SumAsync(s => s.TotalKills); + return kills.ToString("#,##0", + new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)); + } + + async Task TotalPlayTime(Server server) + { + await using var context = _databaseContextFactory.CreateContext(false); + var playTime = await context.Set().Where(s => s.Active).SumAsync(s => s.TotalPlayTime); + return (playTime / 3600.0).ToString("#,##0", + new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName)); + } + + async Task TopStats(Server s) + { + // todo: this needs to needs to be updated when we DI the lookup + return string.Join(Environment.NewLine, + await Commands.TopStats.GetTopStats(s, Utilities.CurrentLocalization.LocalizationIndex, _statManager)); + } + + async Task MostPlayed(Server s) + { + // todo: this needs to needs to be updated when we DI the lookup + return string.Join(Environment.NewLine, + await Commands.MostPlayedCommand.GetMostPlayed(s, Utilities.CurrentLocalization.LocalizationIndex, + _databaseContextFactory)); + } + + async Task MostKills(Server gameServer) + { + return string.Join(Environment.NewLine, + await Commands.MostKillsCommand.GetMostKills(StatManager.GetIdForServer(gameServer), _statsConfig, + _databaseContextFactory, _translationLookup)); + } + + manager.GetMessageTokens().Add(new MessageToken("TOTALKILLS", TotalKills)); + manager.GetMessageTokens().Add(new MessageToken("TOTALPLAYTIME", TotalPlayTime)); + manager.GetMessageTokens().Add(new MessageToken("TOPSTATS", TopStats)); + manager.GetMessageTokens().Add(new MessageToken("MOSTPLAYED", MostPlayed)); + manager.GetMessageTokens().Add(new MessageToken("MOSTKILLS", MostKills)); + + if (_statsConfig.EnableAdvancedMetrics) + { + foreach (var calculator in _statCalculators) + { + await calculator.GatherDependencies(); + } + } + + ServerManager = manager; + await _serverDistributionCalculator.Initialize(); + } + + /// + /// Indicates if the event should be ignored + /// (If the client id or target id is not a real client or the target/origin is a bot and ignore bots is turned on) + /// + /// + /// + /// + private bool ShouldIgnoreEvent(EFClient origin, EFClient target) + { + return origin?.NetworkId == Utilities.WORLD_ID && target?.NetworkId == Utilities.WORLD_ID; + } + + /// + /// Indicates if the damage occurs from world (fall damage/certain killstreaks) + /// + /// + /// + private bool IsWorldDamage(EFClient origin) => + origin?.NetworkId == Utilities.WORLD_ID || origin?.ClientId == Utilities.WORLD_ID; + + /// + /// Indicates if we should try to use anticheat even if sv_customcallbacks is not defined + /// + /// + /// + private bool ShouldOverrideAnticheatSetting(IGameServer gameServer) => _statsConfig.AnticheatConfiguration.Enable && + gameServer.GameCode == Reference.Game.IW5; + + /// + /// Makes sure both clients are added + /// + /// + /// + /// + private async Task EnsureClientsAdded(EFClient origin, EFClient target) + { + await _statManager.AddPlayer(origin); + + if (!origin.Equals(target)) + { + await _statManager.AddPlayer(target); + } + } } diff --git a/Plugins/Stats/Stats.csproj b/Plugins/Stats/Stats.csproj index 3a9f950a7..ac12a59d5 100644 --- a/Plugins/Stats/Stats.csproj +++ b/Plugins/Stats/Stats.csproj @@ -17,7 +17,7 @@ - +