using IW4MAdmin.Plugins.Stats.Helpers; using Microsoft.EntityFrameworkCore; using SharedLibraryCore; using SharedLibraryCore.Dtos.Meta.Responses; using SharedLibraryCore.Helpers; using SharedLibraryCore.Interfaces; using SharedLibraryCore.QueryHelper; using Stats.Dtos; using System; using System.Collections.Generic; 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; public class Plugin : IPluginV2 { 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) { serviceCollection.AddConfiguration("StatsPluginSettings"); serviceCollection.AddSingleton(); } 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; IGameServerEventSubscriptions.MonitoringStopped += async (monitorEvent, token) => await _statManager.Sync(monitorEvent.Server, token); IManagementEventSubscriptions.ClientStateInitialized += async (clientEvent, token) => { if (!_statsConfig.EnableAdvancedMetrics) { return; } foreach (var calculator in _statCalculators) { await calculator.CalculateForEvent(clientEvent); } }; IManagementEventSubscriptions.ClientStateDisposed += async (clientEvent, token) => { await _statManager.RemovePlayer(clientEvent.Client, token); if (!_statsConfig.EnableAdvancedMetrics) { return; } if (clientEvent.Client.ClientId == 0) { _logger.LogWarning("No client id for {Client}, so we are not doing any stat calculation", clientEvent.Client.ToString()); return; } foreach (var calculator in _statCalculators) { await calculator.CalculateForEvent(clientEvent); } }; IGameEventSubscriptions.ClientMessaged += async (messageEvent, token) => { if (!string.IsNullOrEmpty(messageEvent.Message) && messageEvent.Client.ClientId > 1) { await _statManager.AddMessageAsync(messageEvent.Client.ClientId, messageEvent.Server.LegacyDatabaseId, 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); } await EnsureClientsAdded(killEvent.Attacker, killEvent.Victim); await _statManager.AddStandardKill(killEvent.Attacker, killEvent.Victim); if (!_statsConfig.EnableAdvancedMetrics) { return; } foreach (var calculator in _statCalculators) { 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, antiCheatDamageEvent.Server.LegacyDatabaseId, 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, (commandEvent.Client.CurrentServer as IGameServer).LegacyDatabaseId, false, commandEvent.CommandText, token); } } private async Task OnMatchEvent(GameEventV2 gameEvent, CancellationToken token) { _statManager.SetTeamBased(gameEvent.Server.LegacyDatabaseId, 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(IGameServer gameServer) { return string.Join(Environment.NewLine, await Commands.MostKillsCommand.GetMostKills(gameServer.LegacyDatabaseId, _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); } } }