IW4M-Admin/Plugins/Stats/Plugin.cs
2023-08-29 12:31:00 -05:00

590 lines
27 KiB
C#

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<ChatSearchQuery, MessageResponse> _chatQueryHelper;
private readonly ILogger<Plugin> _logger;
private readonly List<IClientStatisticCalculator> _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<StatsConfiguration>("StatsPluginSettings");
serviceCollection.AddSingleton<StatManager>();
}
public Plugin(ILogger<Plugin> logger, IDatabaseContextFactory databaseContextFactory,
ITranslationLookup translationLookup, IMetaServiceV2 metaService,
IResourceQueryHelper<ChatSearchQuery, MessageResponse> chatQueryHelper,
IEnumerable<IClientStatisticCalculator> 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<string>();
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<IEnumerable<InformationResponse>> GetStats(ClientPaginationRequest request,
CancellationToken token = default)
{
await using var ctx = _databaseContextFactory.CreateContext(enableTracking: false);
IList<EFClientStatistics> clientStats = await ctx.Set<EFClientStatistics>()
.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<InformationResponse>
{
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<IEnumerable<InformationResponse>> GetAnticheatInfo(ClientPaginationRequest request,
CancellationToken token = default)
{
await using var context = _databaseContextFactory.CreateContext(enableTracking: false);
IList<EFClientStatistics> clientStats = await context.Set<EFClientStatistics>()
.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<InformationResponse>
{
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<IEnumerable<MessageResponse>> GetMessages(ClientPaginationRequest request,
CancellationToken token = default)
{
var query = new ChatSearchQuery
{
ClientId = request.ClientId,
Before = request.Before,
SentBefore = request.Before ?? DateTime.UtcNow,
SentAfter = request.After,
After = request.After,
Count = request.Count,
IsProfileMeta = true
};
return (await _chatQueryHelper.QueryResource(query)).Results;
}
if (_statsConfig.AnticheatConfiguration.Enable)
{
_metaService.AddRuntimeMeta<ClientPaginationRequest, InformationResponse>(MetaType.Information,
GetAnticheatInfo);
}
_metaService.AddRuntimeMeta<ClientPaginationRequest, InformationResponse>(MetaType.Information, GetStats);
_metaService.AddRuntimeMeta<ClientPaginationRequest, MessageResponse>(MetaType.ChatMessage, GetMessages);
async Task<string> TotalKills(Server server)
{
await using var context = _databaseContextFactory.CreateContext(false);
var kills = await context.Set<EFServerStatistics>().Where(s => s.Active).SumAsync(s => s.TotalKills);
return kills.ToString("#,##0",
new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName));
}
async Task<string> TotalPlayTime(Server server)
{
await using var context = _databaseContextFactory.CreateContext(false);
var playTime = await context.Set<EFServerStatistics>().Where(s => s.Active).SumAsync(s => s.TotalPlayTime);
return (playTime / 3600.0).ToString("#,##0",
new System.Globalization.CultureInfo(Utilities.CurrentLocalization.LocalizationName));
}
async Task<string> 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<string> 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<string> 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();
}
/// <summary>
/// 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)
/// </summary>
/// <param name="origin"></param>
/// <param name="target"></param>
/// <returns></returns>
private bool ShouldIgnoreEvent(EFClient origin, EFClient target)
{
return origin?.NetworkId == Utilities.WORLD_ID && target?.NetworkId == Utilities.WORLD_ID;
}
/// <summary>
/// Indicates if the damage occurs from world (fall damage/certain killstreaks)
/// </summary>
/// <param name="origin"></param>
/// <returns></returns>
private bool IsWorldDamage(EFClient origin) =>
origin?.NetworkId == Utilities.WORLD_ID || origin?.ClientId == Utilities.WORLD_ID;
/// <summary>
/// Indicates if we should try to use anticheat even if sv_customcallbacks is not defined
/// </summary>
/// <param name="gameServer"></param>
/// <returns></returns>
private bool ShouldOverrideAnticheatSetting(IGameServer gameServer) => _statsConfig.AnticheatConfiguration.Enable &&
gameServer.GameCode == Reference.Game.IW5;
/// <summary>
/// Makes sure both clients are added
/// </summary>
/// <param name="origin"></param>
/// <param name="target"></param>
/// <returns></returns>
private async Task EnsureClientsAdded(EFClient origin, EFClient target)
{
await _statManager.AddPlayer(origin);
if (!origin.Equals(target))
{
await _statManager.AddPlayer(target);
}
}
}