mirror of
https://github.com/RaidMax/IW4M-Admin.git
synced 2025-06-29 16:40:24 -05:00
huge commit for advanced stats feature.
broke data out into its own library. may be breaking changes with existing plugins
This commit is contained in:
@ -0,0 +1,11 @@
|
||||
using System.Threading.Tasks;
|
||||
using SharedLibraryCore;
|
||||
|
||||
namespace IW4MAdmin.Plugins.Stats.Client.Abstractions
|
||||
{
|
||||
public interface IClientStatisticCalculator
|
||||
{
|
||||
Task GatherDependencies();
|
||||
Task CalculateForEvent(GameEvent gameEvent);
|
||||
}
|
||||
}
|
10
Plugins/Stats/Client/Abstractions/IHitInfoBuilder.cs
Normal file
10
Plugins/Stats/Client/Abstractions/IHitInfoBuilder.cs
Normal file
@ -0,0 +1,10 @@
|
||||
using IW4MAdmin.Plugins.Stats.Client.Game;
|
||||
using SharedLibraryCore;
|
||||
|
||||
namespace Stats.Client.Abstractions
|
||||
{
|
||||
public interface IHitInfoBuilder
|
||||
{
|
||||
HitInfo Build(string[] log, int entityId, bool isSelf, bool isVictim, Server.Game gameName);
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Stats.Client.Abstractions
|
||||
{
|
||||
public interface IServerDistributionCalculator
|
||||
{
|
||||
Task Initialize();
|
||||
Task<double> GetZScoreForServer(long serverId, double value);
|
||||
Task<double?> GetRatingForZScore(double? value);
|
||||
}
|
||||
}
|
10
Plugins/Stats/Client/Abstractions/IWeaponNameParser.cs
Normal file
10
Plugins/Stats/Client/Abstractions/IWeaponNameParser.cs
Normal file
@ -0,0 +1,10 @@
|
||||
using SharedLibraryCore;
|
||||
using Stats.Client.Game;
|
||||
|
||||
namespace Stats.Client.Abstractions
|
||||
{
|
||||
public interface IWeaponNameParser
|
||||
{
|
||||
WeaponInfo Parse(string weaponName, Server.Game gameName);
|
||||
}
|
||||
}
|
11
Plugins/Stats/Client/Game/AttachmentInfo.cs
Normal file
11
Plugins/Stats/Client/Game/AttachmentInfo.cs
Normal file
@ -0,0 +1,11 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace Stats.Client.Game
|
||||
{
|
||||
public class AttachmentInfo
|
||||
{
|
||||
public string Name { get; set; }
|
||||
}
|
||||
}
|
27
Plugins/Stats/Client/Game/HitInfo.cs
Normal file
27
Plugins/Stats/Client/Game/HitInfo.cs
Normal file
@ -0,0 +1,27 @@
|
||||
using Data.Models;
|
||||
using Stats.Client.Game;
|
||||
|
||||
namespace IW4MAdmin.Plugins.Stats.Client.Game
|
||||
{
|
||||
public enum HitType
|
||||
{
|
||||
Unknown,
|
||||
Kill,
|
||||
Damage,
|
||||
WasKilled,
|
||||
WasDamaged,
|
||||
Suicide
|
||||
}
|
||||
|
||||
public class HitInfo
|
||||
{
|
||||
public Reference.Game Game { get; set; }
|
||||
public int EntityId { get; set; }
|
||||
public bool IsVictim { get; set; }
|
||||
public HitType HitType { get; set; }
|
||||
public int Damage { get; set; }
|
||||
public string Location { get; set; }
|
||||
public string MeansOfDeath { get; set; }
|
||||
public WeaponInfo Weapon { get; set; }
|
||||
}
|
||||
}
|
11
Plugins/Stats/Client/Game/WeaponInfo.cs
Normal file
11
Plugins/Stats/Client/Game/WeaponInfo.cs
Normal file
@ -0,0 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Stats.Client.Game
|
||||
{
|
||||
public class WeaponInfo
|
||||
{
|
||||
public string RawName { get; set; }
|
||||
public string Name { get; set; }
|
||||
public IList<AttachmentInfo> Attachments { get; set; } = new List<AttachmentInfo>();
|
||||
}
|
||||
}
|
611
Plugins/Stats/Client/HitCalculator.cs
Normal file
611
Plugins/Stats/Client/HitCalculator.cs
Normal file
@ -0,0 +1,611 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
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.Client.Stats.Reference;
|
||||
using Data.Models.Server;
|
||||
using IW4MAdmin.Plugins.Stats.Client.Abstractions;
|
||||
using IW4MAdmin.Plugins.Stats.Client.Game;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SharedLibraryCore;
|
||||
using SharedLibraryCore.Database.Models;
|
||||
using Stats.Client.Abstractions;
|
||||
using Stats.Client.Game;
|
||||
|
||||
namespace IW4MAdmin.Plugins.Stats.Client
|
||||
{
|
||||
public class HitState
|
||||
{
|
||||
public HitState()
|
||||
{
|
||||
OnTransaction = new SemaphoreSlim(1, 1);
|
||||
}
|
||||
|
||||
~HitState()
|
||||
{
|
||||
OnTransaction.Dispose();
|
||||
}
|
||||
|
||||
public List<EFClientHitStatistic> 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; }
|
||||
}
|
||||
|
||||
public class HitCalculator : IClientStatisticCalculator
|
||||
{
|
||||
private readonly IDatabaseContextFactory _contextFactory;
|
||||
private readonly ILogger<HitCalculator> _logger;
|
||||
|
||||
private readonly ConcurrentDictionary<int, HitState> _clientHitStatistics =
|
||||
new ConcurrentDictionary<int, HitState>();
|
||||
|
||||
private readonly SemaphoreSlim _onTransaction = new SemaphoreSlim(1, 1);
|
||||
|
||||
private readonly ILookupCache<EFServer> _serverCache;
|
||||
private readonly ILookupCache<EFHitLocation> _hitLocationCache;
|
||||
private readonly ILookupCache<EFWeapon> _weaponCache;
|
||||
private readonly ILookupCache<EFWeaponAttachment> _attachmentCache;
|
||||
private readonly ILookupCache<EFWeaponAttachmentCombo> _attachmentComboCache;
|
||||
private readonly ILookupCache<EFMeansOfDeath> _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<HitCalculator> logger, IDatabaseContextFactory contextFactory,
|
||||
ILookupCache<EFHitLocation> hitLocationCache, ILookupCache<EFWeapon> weaponCache,
|
||||
ILookupCache<EFWeaponAttachment> attachmentCache,
|
||||
ILookupCache<EFWeaponAttachmentCombo> attachmentComboCache,
|
||||
ILookupCache<EFServer> serverCache, ILookupCache<EFMeansOfDeath> 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(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())
|
||||
{
|
||||
await _serverCache.InitializeAsync();
|
||||
}
|
||||
|
||||
gameEvent.Origin.SetAdditionalProperty(SessionScores, new List<(int, DateTime)>());
|
||||
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<List<(int, DateTime)>>(SessionScores);
|
||||
scores?.Add((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.Skip(1).ToArray(), gameEvent.Origin.ClientId,
|
||||
gameEvent.Origin.ClientId == gameEvent.Target.ClientId, false, gameEvent.Owner.GameName);
|
||||
var victimHitInfo = _hitInfoBuilder.Build(match.Values.Skip(1).ToArray(), gameEvent.Target.ClientId,
|
||||
gameEvent.Origin.ClientId == gameEvent.Target.ClientId, true, gameEvent.Owner.GameName);
|
||||
|
||||
foreach (var hitInfo in new[] {attackerHitInfo, victimHitInfo})
|
||||
{
|
||||
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}");
|
||||
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<IEnumerable<EFClientHitStatistic>> 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<List<EFClientHitStatistic>> GetHitsForClient(int clientId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var context = _contextFactory.CreateContext();
|
||||
var hitLocations = await context.Set<EFClientHitStatistic>()
|
||||
.Where(stat => stat.ClientId == clientId)
|
||||
.ToListAsync();
|
||||
|
||||
return !hitLocations.Any() ? new List<EFClientHitStatistic>() : hitLocations;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Could not retrieve {hitName} for client with id {id}",
|
||||
nameof(EFClientHitStatistic), clientId);
|
||||
}
|
||||
|
||||
return new List<EFClientHitStatistic>();
|
||||
}
|
||||
|
||||
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<EFClientHitStatistic>().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<EFClientHitStatistic> 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<EFHitLocation> 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<EFWeapon> 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<EFWeaponAttachment> 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<EFWeaponAttachmentCombo> 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<EFMeansOfDeath> 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<List<(int, DateTime)>>(SessionScores);
|
||||
|
||||
if (sessionScores == null)
|
||||
{
|
||||
_logger.LogWarning($"No session scores available for {client}");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var stat in new[] {serverStats, aggregate})
|
||||
{
|
||||
stat.Score ??= 0;
|
||||
|
||||
if (sessionScores.Count == 0)
|
||||
{
|
||||
stat.Score += client.Score;
|
||||
}
|
||||
|
||||
else
|
||||
{
|
||||
stat.Score += sessionScores.Sum(item => item.Item1) +
|
||||
(sessionScores.Last().Item1 == client.Score &&
|
||||
(DateTime.Now - sessionScores.Last().Item2).TotalMinutes < 1
|
||||
? 0
|
||||
: client.Score);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
64
Plugins/Stats/Client/HitInfoBuilder.cs
Normal file
64
Plugins/Stats/Client/HitInfoBuilder.cs
Normal file
@ -0,0 +1,64 @@
|
||||
using System;
|
||||
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 ILogger = Microsoft.Extensions.Logging.ILogger;
|
||||
|
||||
namespace Stats.Client
|
||||
{
|
||||
public class HitInfoBuilder : IHitInfoBuilder
|
||||
{
|
||||
private readonly IWeaponNameParser _weaponNameParser;
|
||||
private readonly ILogger _logger;
|
||||
private const int MaximumDamage = 1000;
|
||||
|
||||
public HitInfoBuilder(ILogger<HitInfoBuilder> logger, IWeaponNameParser weaponNameParser)
|
||||
{
|
||||
_weaponNameParser = weaponNameParser;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public HitInfo Build(string[] log, int entityId, bool isSelf, bool isVictim, Server.Game gameName)
|
||||
{
|
||||
var eventType = log[(uint) ParserRegex.GroupType.EventType].First();
|
||||
HitType hitType;
|
||||
|
||||
if (isVictim)
|
||||
{
|
||||
if (isSelf)
|
||||
{
|
||||
hitType = HitType.Suicide;
|
||||
}
|
||||
|
||||
else
|
||||
{
|
||||
hitType = eventType == 'D' ? HitType.WasDamaged : HitType.WasKilled;
|
||||
}
|
||||
}
|
||||
|
||||
else
|
||||
{
|
||||
hitType = eventType == 'D' ? HitType.Damage : HitType.Kill;
|
||||
}
|
||||
|
||||
var hitInfo = new HitInfo()
|
||||
{
|
||||
EntityId = entityId,
|
||||
IsVictim = isVictim,
|
||||
HitType = hitType,
|
||||
Damage = Math.Min(MaximumDamage, int.Parse(log[(uint) ParserRegex.GroupType.Damage])),
|
||||
Location = log[(uint) ParserRegex.GroupType.HitLocation],
|
||||
Weapon = _weaponNameParser.Parse(log[(uint) ParserRegex.GroupType.Weapon], gameName),
|
||||
MeansOfDeath = log[(uint)ParserRegex.GroupType.MeansOfDeath],
|
||||
Game = (Reference.Game)gameName
|
||||
};
|
||||
|
||||
//_logger.LogDebug("Generated new hitInfo {@hitInfo}", hitInfo);
|
||||
return hitInfo;
|
||||
}
|
||||
}
|
||||
}
|
150
Plugins/Stats/Client/ServerDistributionCalculator.cs
Normal file
150
Plugins/Stats/Client/ServerDistributionCalculator.cs
Normal file
@ -0,0 +1,150 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Data.Abstractions;
|
||||
using Data.Models.Client;
|
||||
using Data.Models.Client.Stats;
|
||||
using IW4MAdmin.Plugins.Stats;
|
||||
using IW4MAdmin.Plugins.Stats.Config;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SharedLibraryCore;
|
||||
using SharedLibraryCore.Interfaces;
|
||||
using Stats.Client.Abstractions;
|
||||
using Stats.Helpers;
|
||||
|
||||
namespace Stats.Client
|
||||
{
|
||||
public class ServerDistributionCalculator : IServerDistributionCalculator
|
||||
{
|
||||
private readonly IDatabaseContextFactory _contextFactory;
|
||||
|
||||
private readonly IDataValueCache<EFClientStatistics, Dictionary<long, Extensions.LogParams>>
|
||||
_distributionCache;
|
||||
|
||||
private readonly IDataValueCache<EFClientStatistics, double>
|
||||
_maxZScoreCache;
|
||||
|
||||
private readonly IConfigurationHandler<StatsConfiguration> _configurationHandler;
|
||||
private readonly List<long> _serverIds = new List<long>();
|
||||
|
||||
private const string DistributionCacheKey = nameof(DistributionCacheKey);
|
||||
private const string MaxZScoreCacheKey = nameof(MaxZScoreCacheKey);
|
||||
|
||||
public ServerDistributionCalculator(IDatabaseContextFactory contextFactory,
|
||||
IDataValueCache<EFClientStatistics, Dictionary<long, Extensions.LogParams>> distributionCache,
|
||||
IDataValueCache<EFClientStatistics, double> maxZScoreCache,
|
||||
IConfigurationHandlerFactory configFactory)
|
||||
{
|
||||
_contextFactory = contextFactory;
|
||||
_distributionCache = distributionCache;
|
||||
_maxZScoreCache = maxZScoreCache;
|
||||
_configurationHandler = configFactory.GetConfigurationHandler<StatsConfiguration>("StatsPluginSettings");
|
||||
}
|
||||
|
||||
public async Task Initialize()
|
||||
{
|
||||
await LoadServers();
|
||||
_distributionCache.SetCacheItem((async set =>
|
||||
{
|
||||
var validPlayTime = _configurationHandler.Configuration()?.TopPlayersMinPlayTime ?? 3600 * 3;
|
||||
|
||||
var distributions = new Dictionary<long, Extensions.LogParams>();
|
||||
|
||||
await LoadServers();
|
||||
|
||||
foreach (var serverId in _serverIds)
|
||||
{
|
||||
var performance = await set
|
||||
.Where(s => s.ServerId == serverId)
|
||||
.Where(s => s.Skill > 0)
|
||||
.Where(s => s.EloRating > 0)
|
||||
.Where(s => s.Client.Level != EFClient.Permission.Banned)
|
||||
.Where(s => s.TimePlayed >= validPlayTime)
|
||||
.Where(s => s.UpdatedAt >= Extensions.FifteenDaysAgo())
|
||||
.Select(s => s.EloRating * 1/3.0 + s.Skill * 2/3.0).ToListAsync();
|
||||
var distributionParams = performance.GenerateDistributionParameters();
|
||||
distributions.Add(serverId, distributionParams);
|
||||
}
|
||||
|
||||
return distributions;
|
||||
}), DistributionCacheKey, Utilities.IsDevelopment ? TimeSpan.FromMinutes(5) : TimeSpan.FromHours(1));
|
||||
|
||||
_maxZScoreCache.SetCacheItem(async set =>
|
||||
{
|
||||
var validPlayTime = _configurationHandler.Configuration()?.TopPlayersMinPlayTime ?? 3600 * 3;
|
||||
|
||||
var zScore = await set
|
||||
.Where(AdvancedClientStatsResourceQueryHelper.GetRankingFunc(validPlayTime))
|
||||
.Where(s => s.Skill > 0)
|
||||
.Where(s => s.EloRating > 0)
|
||||
.MaxAsync(s => (double?)s.ZScore);
|
||||
return zScore ?? 0;
|
||||
}, MaxZScoreCacheKey, Utilities.IsDevelopment ? TimeSpan.FromMinutes(5) : TimeSpan.FromMinutes(30));
|
||||
|
||||
await _distributionCache.GetCacheItem(DistributionCacheKey);
|
||||
await _maxZScoreCache.GetCacheItem(MaxZScoreCacheKey);
|
||||
|
||||
/*foreach (var serverId in _serverIds)
|
||||
{
|
||||
await using var ctx = _contextFactory.CreateContext(enableTracking: true);
|
||||
|
||||
var a = await ctx.Set<EFClientStatistics>()
|
||||
.Where(s => s.ServerId == serverId)
|
||||
//.Where(s=> s.ClientId == 216105)
|
||||
.Where(s => s.Skill > 0)
|
||||
.Where(s => s.EloRating > 0)
|
||||
.Where(s => s.Client.Level != EFClient.Permission.Banned)
|
||||
.Where(s => s.TimePlayed >= 3600 * 3)
|
||||
.Where(s => s.UpdatedAt >= Extensions.FifteenDaysAgo())
|
||||
.ToListAsync();
|
||||
|
||||
var b = a.Distinct();
|
||||
|
||||
foreach (var item in b)
|
||||
{
|
||||
await Plugin.Manager.UpdateHistoricalRanking(item.ClientId, item, item.ServerId);
|
||||
//item.ZScore = await GetZScoreForServer(serverId, item.Performance);
|
||||
//item.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
await ctx.SaveChangesAsync();
|
||||
}*/
|
||||
}
|
||||
|
||||
private async Task LoadServers()
|
||||
{
|
||||
if (_serverIds.Count == 0)
|
||||
{
|
||||
await using var context = _contextFactory.CreateContext(false);
|
||||
_serverIds.AddRange(await context.Servers
|
||||
.Where(s => s.EndPoint != null && s.HostName != null)
|
||||
.Select(s => s.ServerId)
|
||||
.ToListAsync());
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<double> GetZScoreForServer(long serverId, double value)
|
||||
{
|
||||
var serverParams = await _distributionCache.GetCacheItem(DistributionCacheKey);
|
||||
if (!serverParams.ContainsKey(serverId))
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
var sdParams = serverParams[serverId];
|
||||
if (sdParams.Sigma == 0)
|
||||
{
|
||||
return 0.0;
|
||||
}
|
||||
var zScore = (Math.Log(value) - sdParams.Mean) / sdParams.Sigma;
|
||||
return zScore;
|
||||
}
|
||||
|
||||
public async Task<double?> GetRatingForZScore(double? value)
|
||||
{
|
||||
var maxZScore = await _maxZScoreCache.GetCacheItem(MaxZScoreCacheKey);
|
||||
return maxZScore == 0 ? 0 : value.GetRatingForZScore(maxZScore);
|
||||
}
|
||||
}
|
||||
}
|
75
Plugins/Stats/Client/WeaponNameParser.cs
Normal file
75
Plugins/Stats/Client/WeaponNameParser.cs
Normal file
@ -0,0 +1,75 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Stats.Client.Abstractions;
|
||||
using Stats.Client.Game;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using IW4MAdmin.Plugins.Stats.Config;
|
||||
using SharedLibraryCore;
|
||||
using SharedLibraryCore.Interfaces;
|
||||
using ILogger = Microsoft.Extensions.Logging.ILogger;
|
||||
|
||||
namespace Stats.Client
|
||||
{
|
||||
public class WeaponNameParser : IWeaponNameParser
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly StatsConfiguration _config;
|
||||
|
||||
public WeaponNameParser(ILogger<WeaponNameParser> logger, IConfigurationHandler<StatsConfiguration> config)
|
||||
{
|
||||
_logger = logger;
|
||||
_config = config.Configuration();
|
||||
}
|
||||
|
||||
public WeaponInfo Parse(string weaponName, Server.Game gameName)
|
||||
{
|
||||
var configForGame = _config.WeaponNameParserConfigurations
|
||||
?.FirstOrDefault(config => config.Game == gameName);
|
||||
|
||||
if (configForGame == null)
|
||||
{
|
||||
_logger.LogWarning("No weapon parser config available for game {game}", gameName);
|
||||
return new WeaponInfo()
|
||||
{
|
||||
Name = "Unknown"
|
||||
};
|
||||
}
|
||||
|
||||
var splitWeaponName = weaponName.Split(configForGame.Delimiters);
|
||||
|
||||
if (!splitWeaponName.Any())
|
||||
{
|
||||
_logger.LogError("Could not parse weapon name {weapon}", weaponName);
|
||||
|
||||
return new WeaponInfo()
|
||||
{
|
||||
Name = "Unknown"
|
||||
};
|
||||
}
|
||||
|
||||
// remove the _mp suffix
|
||||
var filtered = splitWeaponName.Where(part => part != configForGame.WeaponSuffix);
|
||||
var baseName = splitWeaponName.First();
|
||||
var attachments = new List<string>();
|
||||
|
||||
if (filtered.Count() > 1)
|
||||
{
|
||||
attachments.AddRange(filtered.Skip(1));
|
||||
}
|
||||
|
||||
var weaponInfo = new WeaponInfo()
|
||||
{
|
||||
RawName = weaponName,
|
||||
Name = baseName,
|
||||
Attachments = attachments.Select(attachment => new AttachmentInfo()
|
||||
{
|
||||
Name = attachment
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
// _logger.LogDebug("Parsed weapon info {@info}", weaponInfo);
|
||||
|
||||
return weaponInfo;
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user