187 lines
6 KiB
C#
187 lines
6 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace AnsonBot.Core;
|
|
|
|
public class TrackerOptions
|
|
{
|
|
public int PollSeconds { get; set; } = 60;
|
|
|
|
/// <summary>
|
|
/// Consecutive polls a player must be absent before their session is
|
|
/// closed. Guards against a single dropped/partial ShowPlayers response
|
|
/// registering as everyone leaving and rejoining.
|
|
/// </summary>
|
|
public int MissedPollsBeforeLeave { get; set; } = 2;
|
|
}
|
|
|
|
public record PlayerEvent(string PlayerId, string Name, TimeSpan Duration);
|
|
|
|
public class PlayerTrackerService : BackgroundService
|
|
{
|
|
private readonly IRconService _rcon;
|
|
private readonly IDbContextFactory<BotDbContext> _dbFactory;
|
|
private readonly TrackerOptions _options;
|
|
private readonly ILogger<PlayerTrackerService> _logger;
|
|
|
|
// PlayerId -> consecutive polls missing.
|
|
private readonly Dictionary<string, int> _missing = new();
|
|
|
|
public event Func<PlayerEvent, Task>? PlayerJoined;
|
|
public event Func<PlayerEvent, Task>? PlayerLeft;
|
|
|
|
public PlayerTrackerService(
|
|
IRconService rcon,
|
|
IDbContextFactory<BotDbContext> dbFactory,
|
|
IOptions<TrackerOptions> options,
|
|
ILogger<PlayerTrackerService> logger)
|
|
{
|
|
_rcon = rcon;
|
|
_dbFactory = dbFactory;
|
|
_options = options.Value;
|
|
_logger = logger;
|
|
}
|
|
|
|
protected override async Task ExecuteAsync(CancellationToken ct)
|
|
{
|
|
await RecoverOrphanedSessionsAsync(ct);
|
|
|
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_options.PollSeconds));
|
|
|
|
do
|
|
{
|
|
try
|
|
{
|
|
await PollAsync(ct);
|
|
}
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
|
{
|
|
break;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Deliberately swallow: a failed poll (server restarting, tunnel
|
|
// blipped) must not kill the loop or close everyone's session.
|
|
_logger.LogWarning(ex, "Poll failed; leaving sessions untouched.");
|
|
}
|
|
}
|
|
while (await timer.WaitForNextTickAsync(ct));
|
|
}
|
|
|
|
/// <summary>
|
|
/// If the bot died without closing sessions, settle them at the last time
|
|
/// we actually saw the player rather than leaving them open forever.
|
|
/// </summary>
|
|
private async Task RecoverOrphanedSessionsAsync(CancellationToken ct)
|
|
{
|
|
await using var db = await _dbFactory.CreateDbContextAsync(ct);
|
|
await db.Database.MigrateAsync(ct);
|
|
|
|
var open = await db.PlaySessions
|
|
.Where(s => s.EndedUtc == null)
|
|
.ToListAsync(ct);
|
|
|
|
if (open.Count == 0) return;
|
|
|
|
foreach (var s in open)
|
|
s.EndedUtc = s.LastSeenUtc;
|
|
|
|
await db.SaveChangesAsync(ct);
|
|
_logger.LogInformation("Closed {Count} orphaned session(s) from a previous run.", open.Count);
|
|
}
|
|
|
|
private async Task PollAsync(CancellationToken ct)
|
|
{
|
|
var raw = await _rcon.SendAsync("ShowPlayers", ct);
|
|
var online = ShowPlayersParser.Parse(raw);
|
|
var now = DateTime.UtcNow;
|
|
|
|
await using var db = await _dbFactory.CreateDbContextAsync(ct);
|
|
|
|
var openSessions = await db.PlaySessions
|
|
.Where(s => s.EndedUtc == null)
|
|
.ToListAsync(ct);
|
|
|
|
var openById = openSessions.ToDictionary(s => s.PlayerId);
|
|
var onlineById = online
|
|
.GroupBy(p => p.Id)
|
|
.ToDictionary(g => g.Key, g => g.First());
|
|
|
|
var joined = new List<PlayerEvent>();
|
|
var left = new List<PlayerEvent>();
|
|
|
|
// Joins, and heartbeat for anyone still connected.
|
|
foreach (var (id, player) in onlineById)
|
|
{
|
|
_missing.Remove(id);
|
|
|
|
if (openById.TryGetValue(id, out var existing))
|
|
{
|
|
existing.LastSeenUtc = now;
|
|
existing.Name = player.Name; // keep up with renames
|
|
}
|
|
else
|
|
{
|
|
db.PlaySessions.Add(new PlaySession
|
|
{
|
|
PlayerId = id,
|
|
Name = player.Name,
|
|
StartedUtc = now,
|
|
LastSeenUtc = now
|
|
});
|
|
|
|
joined.Add(new PlayerEvent(id, player.Name, TimeSpan.Zero));
|
|
}
|
|
}
|
|
|
|
// Leaves, only after enough consecutive absences.
|
|
foreach (var session in openSessions)
|
|
{
|
|
if (onlineById.ContainsKey(session.PlayerId)) continue;
|
|
|
|
var misses = _missing.GetValueOrDefault(session.PlayerId) + 1;
|
|
_missing[session.PlayerId] = misses;
|
|
|
|
if (misses < _options.MissedPollsBeforeLeave) continue;
|
|
|
|
// Credit them only up to when we last actually saw them.
|
|
session.EndedUtc = session.LastSeenUtc;
|
|
_missing.Remove(session.PlayerId);
|
|
|
|
left.Add(new PlayerEvent(
|
|
session.PlayerId, session.Name, session.EndedUtc.Value - session.StartedUtc));
|
|
}
|
|
|
|
await db.SaveChangesAsync(ct);
|
|
|
|
foreach (var e in joined) await RaiseAsync(PlayerJoined, e);
|
|
foreach (var e in left) await RaiseAsync(PlayerLeft, e);
|
|
}
|
|
|
|
private async Task RaiseAsync(Func<PlayerEvent, Task>? handler, PlayerEvent e)
|
|
{
|
|
if (handler is null) return;
|
|
|
|
try
|
|
{
|
|
await handler(e);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// A broken notification must not stop tracking.
|
|
_logger.LogError(ex, "Player event handler failed for {Player}.", e.Name);
|
|
}
|
|
}
|
|
|
|
/// <summary>Currently open sessions, for the /players command.</summary>
|
|
public async Task<IReadOnlyList<PlaySession>> GetOpenSessionsAsync(CancellationToken ct = default)
|
|
{
|
|
await using var db = await _dbFactory.CreateDbContextAsync(ct);
|
|
return await db.PlaySessions
|
|
.Where(s => s.EndedUtc == null)
|
|
.OrderBy(s => s.StartedUtc)
|
|
.ToListAsync(ct);
|
|
}
|
|
}
|