1
0
mirror of https://github.com/RaidMax/IW4M-Admin.git synced 2025-06-08 22:28:15 -05:00
IW4M-Admin/Plugins/ZombieStats/States/ZombieClientStateManager.cs

825 lines
27 KiB
C#

using System.Globalization;
using Data.Abstractions;
using Data.Models;
using Data.Models.Client;
using Data.Models.Client.Stats;
using Data.Models.Zombie;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using SharedLibraryCore;
using SharedLibraryCore.Interfaces;
using ILogger = Microsoft.Extensions.Logging.ILogger;
namespace IW4MAdmin.Plugins.ZombieStats.States;
public class ZombieClientStateManager(
ILogger<ZombieClientStateManager> logger,
IDatabaseContextFactory contextFactory,
ITranslationLookup translations)
{
private readonly ILogger _logger = logger;
private readonly Dictionary<(long, Reference.Game), MatchState> _clientMatches = new();
private readonly List<MatchState> _matches = [];
private readonly List<MatchState> _recentlyEndedMatches = [];
private readonly List<DatedRecord> _addedPersistence = [];
private readonly List<DatedRecord> _updatedPersistence = [];
private readonly SemaphoreSlim _onWorking = new(1, 1);
private Dictionary<string, List<ZombieClientStatRecord>> _recordsCache = null!;
private Dictionary<string, EFClientStatTag> _tagCache = null!;
public async Task Initialize()
{
await using var context = contextFactory.CreateContext(false);
var records = await context.ZombieClientStatRecords.ToListAsync();
_recordsCache = records.GroupBy(record => record.Name)
.ToDictionary(selector => selector.First().Name, selector => selector.ToList());
_tagCache = await context.ClientStatTags.ToDictionaryAsync(kvp => kvp.TagName, kvp => kvp);
}
public async Task UpdateState(CancellationToken token)
{
lock (_addedPersistence)
lock (_updatedPersistence)
{
if (!_addedPersistence.Any() && !_updatedPersistence.Any())
{
return;
}
_logger.LogDebug("[ZM] Updating persistent state for {Count} entries", _addedPersistence.Count);
}
await _onWorking.WaitAsync(token);
await using var context = contextFactory.CreateContext(false);
try
{
IOrderedEnumerable<DatedRecord> addedItems;
lock (_addedPersistence)
{
addedItems = _addedPersistence.ToList().Where(pers => pers.Id == 0).Distinct()
.OrderBy(pers => pers.CreatedDateTime);
}
foreach (var entity in addedItems)
{
try
{
var entry = context.Attach(entity);
entry.State = EntityState.Added;
await context.SaveChangesAsync(token);
}
catch (InvalidOperationException)
{
// ignored
}
finally
{
lock (_addedPersistence)
{
_addedPersistence.Remove(entity);
}
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not persist new object");
}
try
{
IEnumerable<DatedRecord> updatedItems;
lock (_updatedPersistence)
{
updatedItems = _updatedPersistence.ToList().Distinct();
}
foreach (var entity in updatedItems)
{
try
{
var entry = context.Attach(entity);
entry.Entity.UpdatedDateTime = DateTimeOffset.UtcNow;
entry.State = EntityState.Modified;
await context.SaveChangesAsync(token);
}
catch (InvalidOperationException)
{
// ignored
}
finally
{
lock (_updatedPersistence)
{
_updatedPersistence.Remove(entity);
}
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not persist updates to object");
}
_onWorking.Release(1);
}
public MatchState? GetStateForClient(EFClient client) =>
!_clientMatches.TryGetValue((client.NetworkId, client.GameName), out var matchState) ? null : matchState;
public EFClientStatTagValue? GetStatTagValueForClient(EFClient client, string tagName)
{
if (!_tagCache.TryGetValue(tagName, out var statTag))
{
_logger.LogDebug("[ZM] Adding new stat tag {Name}", tagName);
statTag = new EFClientStatTag
{
TagName = tagName
};
_tagCache[tagName] = statTag;
TrackNewState(statTag);
}
var existingState = GetStateForClient(client);
if (existingState is null)
{
return null;
}
if (existingState.PersistentStatTagValues[client.NetworkId].TryGetValue(tagName, out var tagValue))
{
return tagValue;
}
_logger.LogDebug("[ZM] Adding new stat tag value {Name} for {Client}", tagName, client.ToString());
tagValue = new EFClientStatTagValue
{
ClientId = existingState.PersistentMatchAggregateStats[client.NetworkId].ClientId,
StatTag = statTag
};
existingState.PersistentStatTagValues[client.NetworkId][tagName] = tagValue;
TrackNewState(tagValue);
return tagValue;
}
public async Task TrackClient(EFClient client, IGameServer server)
{
await _onWorking.WaitAsync();
try
{
// check if there is an active match for the server
if (_matches.FirstOrDefault(match => match.PersistentMatch.ServerId == server.LegacyDatabaseId) is not
{ } serverMatch)
{
_logger.LogWarning("[ZM] Could not find active match for game server {Server}", server.Id);
serverMatch = CreateMatch(server);
}
_logger.LogDebug("[ZM] Adding {Client} to existing match", client.ToString());
await AddPlayerToMatch(client, serverMatch);
if (_clientMatches.ContainsKey((client.NetworkId, client.GameName)))
{
_clientMatches[(client.NetworkId, client.GameName)] = serverMatch;
}
else
{
_clientMatches.Add((client.NetworkId, client.GameName), serverMatch);
}
}
finally
{
_onWorking.Release(1);
}
}
public void UntrackClient(EFClient client, IGameServer server)
{
if (_matches.FirstOrDefault(m => m.Server.LegacyDatabaseId == server.LegacyDatabaseId) is not { } match)
{
return;
}
_logger.LogDebug("[ZM] Removing {Client} from ZombieStateManager tracking", client.ToString());
_onWorking.Wait();
try
{
match.RoundStates.Remove(client.NetworkId);
match.PersistentLifetimeAggregateStats.Remove(client.NetworkId);
match.PersistentLifetimeServerAggregateStats.Remove(client.NetworkId);
match.PersistentMatchAggregateStats.Remove(client.NetworkId);
match.PersistentStatTagValues.Remove(client.NetworkId);
_clientMatches.Remove((client.NetworkId, client.GameName));
}
finally
{
_onWorking.Release(1);
}
}
private async Task AddPlayerToMatch(EFClient client, MatchState matchState)
{
_logger.LogDebug("[ZM] Adding {Client} to zombie match", client.ToString());
await using var context = contextFactory.CreateContext(false);
var existingAggregates =
await context.ZombieClientStatAggregates.Where(aggr => aggr.ClientId == client.ClientId)
.ToListAsync();
// ReSharper disable once EntityFramework.NPlusOne.IncompleteDataQuery
var matchStat = await context.ZombieMatchClientStats
.Where(match => match.Client.NetworkId == client.NetworkId)
.Where(match => match.MatchId == matchState.PersistentMatch.ZombieMatchId)
.FirstOrDefaultAsync();
var hasConnectedToMatchPreviously = matchStat is not null;
if (matchStat is null &&
matchState.PersistentMatchAggregateStats.TryGetValue(client.NetworkId, out var existingMatchStat))
{
matchStat = existingMatchStat;
}
var isFirstMatchConnection = matchStat is null;
if (isFirstMatchConnection)
{
matchStat = new ZombieMatchClientStat
{
Client = client,
Match = matchState.PersistentMatch
};
TrackNewState(matchStat);
}
else
{
matchStat!.Match = matchState.PersistentMatch;
_logger.LogDebug("[ZM] Connecting client {Client} has existing data for this match {RoundNumber}",
client.ToString(), matchState.RoundNumber);
}
if (!matchState.RoundStates.ContainsKey(client.NetworkId))
{
var roundStat = await context.ZombieRoundClientStats
.Where(round => round.Client.NetworkId == client.NetworkId)
.Where(round => round.MatchId == matchState.PersistentMatch.ZombieMatchId)
.Where(round => round.RoundNumber == matchState.RoundNumber)
.FirstOrDefaultAsync();
var roundState = new RoundState
{
PersistentClientRound = roundStat ?? new ZombieRoundClientStat
{
Client = client,
Match = matchState.PersistentMatch,
RoundNumber = 1
}
};
if (roundStat is null)
{
TrackNewState(roundState.PersistentClientRound);
}
else
{
_logger.LogDebug("[ZM] Connecting client {Client} has existing data for this round {RoundNumber}",
client.ToString(), matchState.RoundNumber);
}
matchState.RoundStates[client.NetworkId] = roundState;
}
var existingLifetimeAggregate = existingAggregates.FirstOrDefault(aggregate => aggregate.ServerId is null);
var existingLifetimeServerAggregate = existingAggregates.FirstOrDefault(aggregate =>
aggregate.ServerId != null && aggregate.ServerId == matchStat.Match.ServerId);
if (existingLifetimeAggregate is null)
{
existingLifetimeAggregate = new ZombieAggregateClientStat
{
Client = client,
TotalMatchesPlayed = 1
};
TrackNewState(existingLifetimeAggregate);
}
else if (!hasConnectedToMatchPreviously)
{
existingLifetimeAggregate.TotalMatchesPlayed++;
}
if (existingLifetimeServerAggregate is null)
{
existingLifetimeServerAggregate = new ZombieAggregateClientStat
{
Client = client,
ServerId = matchStat.Match.ServerId,
TotalMatchesPlayed = 1
};
TrackNewState(existingLifetimeServerAggregate);
}
else if (!hasConnectedToMatchPreviously)
{
existingLifetimeServerAggregate.TotalMatchesPlayed++;
}
var statValues = await context.ClientStatTagValues
.Where(stat => stat.ClientId == client.ClientId)
.ToDictionaryAsync(selector => selector.StatTag.TagName, selector => selector);
matchState.PersistentMatchAggregateStats[client.NetworkId] = matchStat;
matchState.PersistentLifetimeAggregateStats[client.NetworkId] = existingLifetimeAggregate;
matchState.PersistentLifetimeServerAggregateStats[client.NetworkId] = existingLifetimeServerAggregate;
matchState.PersistentStatTagValues[client.NetworkId] = statValues;
}
private void CarryOverPlayerToMatch(EFClient client, MatchState matchState)
{
_logger.LogDebug("[ZM] Client is carrying over from last match {Client}", client.ToString());
matchState.PersistentMatchAggregateStats[client.NetworkId] = new ZombieMatchClientStat
{
Client = client,
Match = matchState.PersistentMatch
};
TrackNewState(matchState.PersistentMatchAggregateStats[client.NetworkId]);
if (matchState.PersistentLifetimeAggregateStats.TryGetValue(client.NetworkId, out var lifetimeStats))
{
lifetimeStats.TotalMatchesPlayed++;
}
if (matchState.PersistentLifetimeServerAggregateStats.TryGetValue(client.NetworkId,
out var lifetimeServerStats))
{
lifetimeServerStats.TotalMatchesPlayed++;
}
}
public MatchState CreateMatch(IGameServer server)
{
if (_matches.FirstOrDefault(match => match.Server.Id == server.Id) is { } currentMatch)
{
_logger.LogWarning("[ZM] Cannot create a new zombie match. One already in progress");
return currentMatch;
}
_logger.LogDebug("[ZM] Creating zombie match for {Server}", server.Id);
var currentMap = contextFactory.CreateContext(false).Maps.FirstOrDefault(map => map.Name == server.Map.Name);
var matchPersistence = new ZombieMatch
{
ServerId = server.LegacyDatabaseId,
MatchStartDate = DateTimeOffset.UtcNow,
MapId = currentMap?.MapId
};
var newMatch = new MatchState(server, matchPersistence);
_matches.Add(newMatch);
if (_recentlyEndedMatches.FirstOrDefault(match => match.Server.Id == server.Id) is { } previousMatch)
{
foreach (var entry in previousMatch.PersistentLifetimeAggregateStats)
{
newMatch.PersistentLifetimeAggregateStats.Add(entry.Key, entry.Value);
}
foreach (var entry in previousMatch.PersistentLifetimeServerAggregateStats)
{
newMatch.PersistentLifetimeServerAggregateStats.Add(entry.Key, entry.Value);
}
foreach (var entry in previousMatch.PersistentStatTagValues)
{
newMatch.PersistentStatTagValues[entry.Key] = entry.Value;
}
_recentlyEndedMatches.Remove(previousMatch);
}
foreach (var client in server.ConnectedClients.Where(client =>
client.State == SharedLibraryCore.Database.Models.EFClient.ClientState.Connected))
{
_logger.LogDebug("[ZM] Adding connected {Client} to new zombie match", client);
_clientMatches[(client.NetworkId, client.GameName)] = newMatch;
CarryOverPlayerToMatch(client, newMatch);
}
TrackNewState(matchPersistence);
StartNextRound(1, server);
return newMatch;
}
public void TrackUpdatedState(DatedRecord state)
{
lock (_addedPersistence)
lock (_updatedPersistence)
{
if (!_updatedPersistence.Contains(state) && !_addedPersistence.Contains(state))
{
_updatedPersistence.Add(state);
}
}
}
private void TrackNewState(DatedRecord state)
{
lock (_addedPersistence)
lock (_updatedPersistence)
{
if (!_addedPersistence.Contains(state) && !_updatedPersistence.Contains(state))
{
_addedPersistence.Add(state);
}
}
}
public void StartNextRound(int round, IGameServer server)
{
if (_matches.FirstOrDefault(match => match.Server.Id == server.Id) is not { } matchState)
{
_logger.LogWarning("[ZM] Cannot start next round, no active match for {Server}", server.Id);
return;
}
// make sure it's a greater round than previous
if (matchState.RoundNumber >= round)
{
return;
}
//_onWorking.Wait();
_logger.LogDebug("[ZM] Starting Round {RoundNumber} for {Server}", round, server.Id);
matchState.RoundNumber = round;
var clients = server.ConnectedClients;
matchState.RoundStates.Clear();
foreach (var client in clients.Where(c =>
_clientMatches.ContainsKey((c.NetworkId, c.GameName)) &&
c.State == SharedLibraryCore.Database.Models.EFClient.ClientState.Connected))
{
_logger.LogDebug("[ZM] Updating current round {Client}", client.ToString());
if (matchState.RoundStates.TryGetValue(client.NetworkId, out var existingRoundState) &&
existingRoundState.PersistentClientRound.RoundNumber == round)
{
_logger.LogDebug("[ZM] Round state data already exists for Client {Client}", client);
continue;
}
var roundState = new RoundState
{
PersistentClientRound = new ZombieRoundClientStat
{
Client = client,
Match = matchState.PersistentMatch,
RoundNumber = round
}
};
if (matchState.PersistentMatchAggregateStats.TryGetValue(client.NetworkId, out var matchStats))
{
matchStats.JoinedRound ??= round;
}
TrackNewState(roundState.PersistentClientRound);
matchState.RoundStates[client.NetworkId] = roundState;
}
//_onWorking.Release(1);
}
public void EndMatch(IGameServer gameServer)
{
if (_matches.FirstOrDefault(match => match.Server.Id == gameServer.Id) is not { } matchState)
{
_logger.LogWarning("[ZM] Cannot end zombie match. Server has not started a match");
return;
}
_onWorking.Wait();
try
{
_logger.LogDebug("[ZM] Ending match for zombie match on {Server}", matchState.Server.Id);
matchState.PersistentMatch.MatchEndDate = DateTimeOffset.UtcNow;
_matches.Remove(matchState);
_recentlyEndedMatches.Add(matchState);
foreach (var kvp in _clientMatches.ToList().Where(kvp => kvp.Value == matchState))
{
_clientMatches.Remove(kvp.Key);
}
TrackUpdatedState(matchState.PersistentMatch);
}
finally
{
_onWorking.Release(1);
}
}
public ZombieClientStatRecord? GetClientNumericalRecord(string recordKey, RecordType type = RecordType.Maximum)
{
return _recordsCache.TryGetValue(recordKey, out var values)
? values.FirstOrDefault(record => record.Type == type.ToString())
: null;
}
public ZombieClientStatRecord CreateClientNumericalRecord(EFClient client, ZombieRoundClientStat round,
string recordKey,
double recordValue)
{
_onWorking.Wait();
try
{
var newRecord = new ZombieClientStatRecord
{
Name = recordKey,
Value = Convert.ToString(recordValue, CultureInfo.InvariantCulture),
Type = RecordType.Maximum.ToString(),
Client = client,
Round = round
};
if (_recordsCache.TryGetValue(recordKey, out var values))
{
values.Add(newRecord);
}
else
{
_recordsCache.Add(recordKey, [newRecord]);
}
TrackNewState(newRecord);
return newRecord;
}
finally
{
_onWorking.Release(1);
}
}
public async Task GetTopStatsMetrics(Dictionary<int, List<EFMeta>> meta, long? serverId, string performanceBucket,
bool isTopStats)
{
if (!isTopStats)
{
return;
}
var clientIds = meta.Keys.ToList();
await using var context = contextFactory.CreateContext(false);
var stats = await context.ZombieClientStatAggregates
.Where(stat => clientIds.Contains(stat.ClientId))
.Where(stat => stat.ServerId == serverId)
.ToListAsync();
foreach (var stat in stats)
{
meta[stat.ClientId].Add(new EFMeta
{
Value = stat.HighestRound.ToNumericalString(),
Key = "Highest Round"
});
meta[stat.ClientId].Add(new EFMeta
{
Value = stat.TotalRoundsPlayed.ToNumericalString(),
Key = "Total Rounds Played"
});
meta[stat.ClientId].First(m => m.Extra == "Deaths").Value = stat.Deaths.ToNumericalString();
}
}
public async Task GetAdvancedStatsMetrics(Dictionary<int, List<EFMeta>> meta, long? serverId,
string performanceBucketCode,
bool isTopStats)
{
if (isTopStats || !meta.Any())
{
return;
}
var clientId = meta.First().Key;
await using var context = contextFactory.CreateContext(false);
var iqStats = context.ZombieClientStatAggregates
.Where(stat => stat.ClientId == clientId);
iqStats = !string.IsNullOrEmpty(performanceBucketCode)
? iqStats.Where(stat => stat.Server.PerformanceBucket.Code == performanceBucketCode)
: iqStats.Where(stat => stat.ServerId == serverId);
var stats = await iqStats.Select(stat => new
{
stat.HeadshotKills,
stat.DamageDealt,
stat.DamageReceived,
stat.Downs,
stat.Revives,
stat.PointsEarned,
stat.PointsSpent,
stat.PerksConsumed,
stat.PowerupsGrabbed,
stat.HighestRound,
stat.TotalRoundsPlayed,
stat.TotalMatchesPlayed,
stat.TotalMatchesCompleted,
stat.HeadshotPercentage,
stat.AverageRoundReached,
stat.AveragePoints,
stat.AverageDowns,
stat.AverageRevives
})
.FirstOrDefaultAsync();
if (stats is null)
{
return;
}
var tagValues = await context.ClientStatTagValues
.Where(tag => tag.ClientId == clientId)
.Select(tag => new
{
tag.StatValue,
tag.StatTag.TagName
})
.ToListAsync();
meta.First().Value.AddRange(new List<EFMeta>
{
new()
{
Key = "Headshot Kills",
Value = stats.HeadshotKills.ToNumericalString()
},
new()
{
Key = "Damage Dealt",
Value = stats.DamageDealt.ToNumericalString()
},
new()
{
Key = "Damage Received",
Value = stats.DamageReceived.ToNumericalString()
},
new()
{
Key = "Downs",
Value = stats.Downs.ToNumericalString()
},
new()
{
Key = "Revives",
Value = stats.Revives.ToNumericalString()
},
new()
{
Key = "Points Earned",
Value = stats.PointsEarned.ToNumericalString()
},
new()
{
Key = "Points Spent",
Value = stats.PointsSpent.ToNumericalString()
},
new()
{
Key = "Perks Consumed",
Value = stats.PerksConsumed.ToNumericalString()
},
new()
{
Key = "Powerups Grabbed",
Value = stats.PowerupsGrabbed.ToNumericalString()
},
new()
{
Key = "Highest Round",
Value = stats.HighestRound.ToNumericalString()
},
new()
{
Key = "Rounds Played",
Value = stats.TotalRoundsPlayed.ToNumericalString()
},
new()
{
Key = "Matches Played",
Value = stats.TotalMatchesPlayed.ToNumericalString()
},
new()
{
Key = "Matches Completed",
Value = stats.TotalMatchesCompleted.ToNumericalString()
},
new()
{
Key = "Quit Rate",
Value = (stats.TotalMatchesCompleted == 0
? 100
: stats.TotalMatchesCompleted - stats.TotalMatchesPlayed == 0
? 0
: (int)Math.Round((1 - stats.TotalMatchesCompleted / (double)stats.TotalMatchesPlayed) *
100.0))
.ToNumericalString() + "%"
},
new()
{
Key = "Headshot Percentage",
Value = (stats.HeadshotPercentage * 100.0).ToNumericalString() + "%"
},
new()
{
Key = "Avg. Round Reached",
Value = stats.AverageRoundReached.ToNumericalString(1)
},
new()
{
Key = "Avg. Points",
Value = stats.AveragePoints.ToNumericalString()
},
new()
{
Key = "Avg. Downs",
Value = stats.AverageDowns.ToNumericalString(2)
},
new()
{
Key = "Avg. Revives",
Value = stats.AverageRevives.ToNumericalString(2)
}
});
meta.First().Value.AddRange(tagValues.Select(tag => new EFMeta
{
Key = translations[$"WEBFRONT_STAT_TAG_{tag.TagName.ToUpper()}"],
Value = tag.StatValue?.ToNumericalString() ?? "-"
}));
}
public void TrackEventForLog(IGameServer gameServer, EventLogType eventType, EFClient? sourceClient = null,
EFClient? associatedClient = null, double? numericalValue = null, string? textualValue = null)
{
var match = (ZombieMatch?)null;
var matchedClient = sourceClient;
if (sourceClient is not null)
{
if (_clientMatches.TryGetValue((sourceClient.NetworkId, sourceClient.GameName), out var foundMatch))
{
if (foundMatch.PersistentLifetimeAggregateStats.TryGetValue(sourceClient.ClientId, out var foundStat))
{
matchedClient = foundStat.Client;
}
}
match = GetStateForClient(sourceClient)?.PersistentMatch;
}
match ??= _matches
.FirstOrDefault(state => state.PersistentMatch.ServerId == gameServer.LegacyDatabaseId)
?.PersistentMatch;
var eventLogData = new ZombieEventLog
{
EventType = eventType,
SourceClient = matchedClient,
AssociatedClient = associatedClient,
NumericalValue = numericalValue,
TextualValue = textualValue,
Match = match
};
TrackNewState(eventLogData);
}
}